Autosave: 20260629-071644

This commit is contained in:
Flatlogic Bot 2026-06-29 07:16:39 +00:00
parent df9c6cb725
commit 3dfd47bae8
21 changed files with 1742 additions and 206 deletions

View File

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

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

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

View File

@ -15,6 +15,8 @@ const fileRoutes = require('./routes/file');
const searchRoutes = require('./routes/search');
const sqlRoutes = require('./routes/sql');
const pexelsRoutes = require('./routes/pexels');
const plansRoutes = require('./routes/plans');
const subscriptionRoutes = require('./routes/subscription');
const openaiRoutes = require('./routes/openai');
@ -99,6 +101,8 @@ app.use(bodyParser.json());
app.use('/api/auth', authRoutes);
app.use('/api/file', fileRoutes);
app.use('/api/pexels', pexelsRoutes);
app.use('/api/plans', plansRoutes);
app.use('/api/subscription', passport.authenticate('jwt', {session: false}), subscriptionRoutes);
app.enable('trust proxy');

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

@ -2,6 +2,7 @@ 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();
@ -189,6 +190,16 @@ router.post('/request', wrapAsync(async (req, res) => {
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

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
const crypto = require('crypto');
const db = require('../db/models');
const SubscriptionService = require('./subscription');
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const ZERO_DECIMAL_CURRENCIES = new Set([
@ -742,6 +743,8 @@ async function connectProvider(currentUser, body, req) {
}
if (!business) {
await SubscriptionService.assertCanCreateBusinesses(currentUser, 1);
const createPayload = {
name: businessName,
review_destination: reviewDestination,
@ -1002,21 +1005,37 @@ async function processPaymentWebhook(providerName, businessId, secretToken, payl
const customer = await createCustomerFromPayment(payment, business, transaction);
const { transactionRecord, duplicate } = await createTransactionFromPayment(payment, business, customer, transaction);
const reviewRequest = duplicate ? null : await createReviewRequestFromPayment(
payment,
business,
customer,
transactionRecord,
transaction,
headers,
);
let reviewRequest = null;
let subscriptionLimitMessage = null;
if (!duplicate && payment.isPaymentSuccess && customer) {
const reviewLimit = await SubscriptionService.canCreateReviewRequests(ownerId, 1, { transaction });
if (reviewLimit.allowed) {
reviewRequest = await createReviewRequestFromPayment(
payment,
business,
customer,
transactionRecord,
transaction,
headers,
);
} else {
subscriptionLimitMessage = reviewLimit.message;
}
}
let processingError = null;
if (payment.isPaymentSuccess && !customer) {
processingError = 'Payment was saved, but no customer email was present, so no review request was queued.';
}
if (payment.isPaymentSuccess && customer && !reviewRequest && !duplicate) {
if (payment.isPaymentSuccess && customer && subscriptionLimitMessage) {
processingError = subscriptionLimitMessage;
}
if (payment.isPaymentSuccess && customer && !reviewRequest && !duplicate && !subscriptionLimitMessage) {
processingError = 'Payment was saved, but the business has no review link, so no review request was queued.';
}

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

@ -98,7 +98,8 @@ const providerOptions = [
label: 'Shopify',
categoryLabel: 'Ecommerce order trigger + hosted reviews',
defaultReviewDestination: 'shopify_hosted',
description: 'Connect paid Shopify orders; customers review products on a hosted Review Flow form.',
description:
'Connect paid Shopify orders; customers review products on a hosted Review Flow form.',
},
{
key: 'woocommerce',
@ -150,7 +151,8 @@ const reviewDestinationOptions = [
label: 'Shopify hosted product review',
group: 'Ecommerce review destinations',
mode: 'hosted_form',
description: 'Review Flow hosts the product review form after a Shopify paid order.',
description:
'Review Flow hosts the product review form after a Shopify paid order.',
},
{
key: 'trustpilot',
@ -171,12 +173,14 @@ const reviewDestinationOptions = [
const reviewDestinationGroups = [
{
title: 'Local review destinations',
subtitle: 'Google, Facebook, Yelp, Angi, and OpenTable send customers to the correct local profile/review page.',
subtitle:
'Google, Facebook, Yelp, Angi, and OpenTable send customers to the correct local profile/review page.',
keys: ['google', 'facebook', 'yelp', 'angi', 'opentable'],
},
{
title: 'Ecommerce review destinations',
subtitle: 'Shopify uses Review Flow hosted product reviews. Trustpilot stays separate as an ecommerce review link destination.',
subtitle:
'Shopify uses Review Flow hosted product reviews. Trustpilot stays separate as an ecommerce review link destination.',
keys: ['shopify_hosted', 'trustpilot'],
},
];
@ -303,6 +307,185 @@ const providerSetupDetails: Record<
},
};
type ProviderApiBackup = {
summary: string;
samplePayload: string;
successTip: string;
};
const commonApiBackupUseCases = [
'Use this only after trying the provider dashboard webhook first.',
'Good for a custom backend, Zapier, Make, n8n, or middleware that already knows the payment/order succeeded.',
'Post JSON to the same secure Review Flow URL. Keep that URL private because it includes the provider secret token.',
];
const providerApiBackups: Record<string, ProviderApiBackup> = {
stripe: {
summary:
'If Stripe webhooks are blocked or you run a custom checkout service, send a Stripe-style successful payment event to Review Flow after payment confirmation.',
samplePayload: JSON.stringify(
{
id: 'evt_api_backup_stripe_001',
type: 'checkout.session.completed',
data: {
object: {
id: 'cs_test_review_flow_001',
object: 'checkout.session',
payment_intent: 'pi_review_flow_001',
amount_total: 12900,
currency: 'usd',
payment_status: 'paid',
customer: 'cus_review_flow_001',
customer_email: 'customer@example.com',
customer_details: {
name: 'Alex Customer',
email: 'customer@example.com',
phone: '+15555550100',
},
created: 1793232000,
},
},
},
null,
2,
),
successTip:
'Review Flow needs a successful Stripe event type and a customer email to create the customer, transaction, and review request.',
},
paypal: {
summary:
'If PayPal webhook setup is unavailable, your server or automation tool can send a PayPal-style completed capture/sale event after the payment settles.',
samplePayload: JSON.stringify(
{
id: 'WH-API-BACKUP-PAYPAL-001',
event_type: 'PAYMENT.CAPTURE.COMPLETED',
create_time: '2026-06-29T12:00:00Z',
resource: {
id: 'PAYPAL-CAPTURE-001',
amount: {
value: '129.00',
currency_code: 'USD',
},
payer: {
payer_id: 'PAYER123',
email_address: 'customer@example.com',
name: {
given_name: 'Alex',
surname: 'Customer',
},
},
description: 'Paid invoice #1001',
create_time: '2026-06-29T12:00:00Z',
},
},
null,
2,
),
successTip:
'Review Flow queues the request when the PayPal event is completed and the payload includes the payer email.',
},
square: {
summary:
'If Square webhook subscriptions are not practical, send a Square-style payment.created or payment.updated event once the payment status is COMPLETED.',
samplePayload: JSON.stringify(
{
event_id: 'square-api-backup-001',
type: 'payment.updated',
created_at: '2026-06-29T12:00:00Z',
data: {
object: {
payment: {
id: 'SQ-PAYMENT-001',
status: 'COMPLETED',
total_money: {
amount: 12900,
currency: 'USD',
},
buyer_email_address: 'customer@example.com',
customer_id: 'SQ-CUSTOMER-001',
customer: {
given_name: 'Alex',
family_name: 'Customer',
email_address: 'customer@example.com',
phone_number: '+15555550100',
},
note: 'Square payment #1001',
created_at: '2026-06-29T12:00:00Z',
},
},
},
},
null,
2,
),
successTip:
'Review Flow checks for a Square payment event, COMPLETED status, and a buyer email before queuing a review.',
},
shopify: {
summary:
'If Shopify webhooks cannot be configured, send a Shopify-style paid order payload after your store confirms the order is paid.',
samplePayload: JSON.stringify(
{
topic: 'orders/paid',
id: 1001001,
name: '#1001',
order_number: 1001,
financial_status: 'paid',
email: 'customer@example.com',
contact_email: 'customer@example.com',
total_price: '129.00',
currency: 'USD',
processed_at: '2026-06-29T12:00:00Z',
customer: {
id: 501,
first_name: 'Alex',
last_name: 'Customer',
email: 'customer@example.com',
},
line_items: [
{
product_id: 9001,
variant_id: 8001,
name: 'Premium Product',
sku: 'PREMIUM-001',
quantity: 1,
},
],
},
null,
2,
),
successTip:
'Review Flow creates a hosted product-review page when the Shopify event is orders/paid or financial_status is paid and an email is present.',
},
woocommerce: {
summary:
'If WooCommerce webhook delivery is unreliable, send a WooCommerce-style order payload after the order status becomes processing or completed.',
samplePayload: JSON.stringify(
{
topic: 'order.updated',
id: 1001,
number: '1001',
order_key: 'wc_order_review_flow_001',
status: 'processing',
total: '129.00',
currency: 'USD',
date_created: '2026-06-29T12:00:00Z',
billing: {
first_name: 'Alex',
last_name: 'Customer',
email: 'customer@example.com',
phone: '+15555550100',
},
},
null,
2,
),
successTip:
'Review Flow queues a review for WooCommerce orders with processing/completed status and a billing email.',
},
};
const providerGradient: Record<string, string> = {
stripe: 'from-indigo-600 to-violet-600',
square: 'from-emerald-600 to-teal-600',
@ -362,6 +545,12 @@ export default function PaymentProviderConnectors({
providerOptions.find(
(provider) => provider.key === connectorForm.provider,
) || providerOptions[0];
const selectedSetup =
providerSetupDetails[selectedProvider.key] || providerSetupDetails.stripe;
const selectedApiBackup =
providerApiBackups[selectedProvider.key] || providerApiBackups.stripe;
const selectedProviderGradient =
providerGradient[selectedProvider.key] || providerGradient.stripe;
const effectiveReviewDestination =
selectedProvider.defaultReviewDestination === 'shopify_hosted'
? 'shopify_hosted'
@ -396,6 +585,24 @@ export default function PaymentProviderConnectors({
};
}, [connectors]);
const selectedWebhookTargets = useMemo(
() =>
connectors.flatMap((business) =>
(business.providers || [])
.filter(
(provider) =>
provider.key === selectedProvider.key &&
Boolean(provider.webhook_url),
)
.map((provider) => ({
businessId: business.id,
businessName: business.name || 'Connected business',
url: provider.webhook_url || '',
})),
),
[connectors, selectedProvider.key],
);
const updateConnectorForm = (
key: keyof ConnectorFormValues,
value: string,
@ -405,8 +612,9 @@ export default function PaymentProviderConnectors({
const updateSelectedProvider = (providerKey: string) => {
const provider =
providerOptions.find((providerOption) => providerOption.key === providerKey) ||
providerOptions[0];
providerOptions.find(
(providerOption) => providerOption.key === providerKey,
) || providerOptions[0];
setConnectorForm((current) => ({
...current,
@ -565,10 +773,11 @@ export default function PaymentProviderConnectors({
/>
Secure connection note
</div>
Payment and ecommerce providers POST order/payment events to a public webhook URL.
Local review destinations do not use these webhooks; they are the places customers
visit after a request. Shopify is the exception here: it triggers from orders and
Review Flow hosts the product-review form.
Payment and ecommerce providers POST order/payment events to a public
webhook URL. Local review destinations do not use these webhooks; they
are the places customers visit after a request. Shopify is the
exception here: it triggers from orders and Review Flow hosts the
product-review form.
</div>
</div>
@ -620,36 +829,47 @@ export default function PaymentProviderConnectors({
))}
</div>
<div className='mb-6 grid gap-3 sm:grid-cols-2 xl:grid-cols-5'>
{providerOptions.map((provider) => {
const isSelected = connectorForm.provider === provider.key;
return (
<button
key={provider.key}
type='button'
onClick={() => updateSelectedProvider(provider.key)}
className={`rounded-2xl border p-4 text-left transition hover:-translate-y-0.5 hover:shadow-lg ${
isSelected
? 'border-indigo-300 bg-indigo-50 ring-2 ring-indigo-100 dark:bg-indigo-950/30 dark:ring-indigo-900'
: 'border-slate-200 bg-white dark:border-dark-700 dark:bg-dark-900'
}`}
>
<div
className={`mb-3 h-2 rounded-full bg-gradient-to-r ${providerGradient[provider.key] || providerGradient.stripe}`}
/>
<p className='text-xs font-bold uppercase tracking-[0.18em] text-slate-400'>
{provider.categoryLabel}
</p>
<p className='mt-1 font-black text-slate-900 dark:text-white'>
Connect {provider.label}
</p>
<p className='mt-1 text-sm text-slate-500 dark:text-slate-400'>
{provider.description}
</p>
</button>
);
})}
<div className='mb-6 rounded-2xl border border-indigo-100 bg-indigo-50/70 p-4 dark:border-indigo-900 dark:bg-indigo-950/20'>
<p className='text-sm font-bold uppercase tracking-[0.25em] text-indigo-500'>
Cleaner setup flow
</p>
<h4 className='mt-1 text-xl font-black text-slate-900 dark:text-white'>
Choose one provider, then follow one guide
</h4>
<p className='mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300'>
The provider dropdown below now controls the instructions. Review Flow
shows the detailed webhook setup for only the selected payment or
ecommerce company, then shows an API backup underneath.
</p>
<div className='mt-4 grid gap-3 md:grid-cols-3'>
<div className='rounded-xl bg-white p-3 text-sm ring-1 ring-indigo-100 dark:bg-dark-900 dark:ring-indigo-900'>
<p className='font-black text-slate-900 dark:text-white'>
1. Select provider
</p>
<p className='mt-1 text-slate-500 dark:text-slate-400'>
Pick Stripe, PayPal, Square, Shopify, or WooCommerce from the
dropdown.
</p>
</div>
<div className='rounded-xl bg-white p-3 text-sm ring-1 ring-indigo-100 dark:bg-dark-900 dark:ring-indigo-900'>
<p className='font-black text-slate-900 dark:text-white'>
2. Connect webhook
</p>
<p className='mt-1 text-slate-500 dark:text-slate-400'>
Connect first so Review Flow generates the secure URL for that
provider.
</p>
</div>
<div className='rounded-xl bg-white p-3 text-sm ring-1 ring-indigo-100 dark:bg-dark-900 dark:ring-indigo-900'>
<p className='font-black text-slate-900 dark:text-white'>
3. Use API backup if needed
</p>
<p className='mt-1 text-slate-500 dark:text-slate-400'>
If dashboard webhooks are unavailable, POST provider-style JSON to
the same URL.
</p>
</div>
</div>
</div>
<form
@ -692,13 +912,18 @@ export default function PaymentProviderConnectors({
>
<select
value={effectiveReviewDestination}
disabled={selectedProvider.defaultReviewDestination === 'shopify_hosted'}
disabled={
selectedProvider.defaultReviewDestination === 'shopify_hosted'
}
onChange={(event) =>
updateConnectorForm('reviewDestination', event.target.value)
}
>
{reviewDestinationOptions.map((destination) => (
<option key={`${destination.key}-destination`} value={destination.key}>
<option
key={`${destination.key}-destination`}
value={destination.key}
>
{destination.label}
</option>
))}
@ -715,13 +940,17 @@ export default function PaymentProviderConnectors({
/>
</FormField>
<FormField
label={isHostedReviewDestination ? 'Hosted review form' : 'External review link'}
label={
isHostedReviewDestination
? 'Hosted review form'
: 'External review link'
}
help={selectedReviewDestination.description}
>
{isHostedReviewDestination ? (
<div className='rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-sm font-semibold text-emerald-900'>
No external Shopify review URL is needed. Review Flow generates a secure hosted
product-review page for each Shopify paid order.
No external Shopify review URL is needed. Review Flow generates a
secure hosted product-review page for each Shopify paid order.
</div>
) : (
<input
@ -758,82 +987,169 @@ export default function PaymentProviderConnectors({
</div>
</form>
<div className='mb-6 rounded-2xl border border-slate-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900'>
<div>
<p className='text-sm font-bold uppercase tracking-[0.25em] text-emerald-500'>
Installation guide
</p>
<h4 className='text-xl font-black text-slate-900 dark:text-white'>
How to install provider webhooks
</h4>
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400'>
First connect a provider above to generate the secure Review Flow
webhook URL. Then follow the matching provider instructions below
and use the copied URL in that provider dashboard.
</p>
<div className='mb-6 overflow-hidden rounded-2xl border border-slate-200 bg-white dark:border-dark-700 dark:bg-dark-900'>
<div
className={`bg-gradient-to-r ${selectedProviderGradient} p-5 text-white`}
>
<div className='flex flex-wrap items-start justify-between gap-4'>
<div>
<p className='text-xs font-bold uppercase tracking-[0.25em] text-white/70'>
Selected provider guide
</p>
<h4 className='mt-1 text-2xl font-black'>
{selectedProvider.label} setup
</h4>
<p className='mt-2 max-w-3xl text-sm leading-6 text-white/85'>
Follow the webhook instructions first. API backup is available
below for custom systems or automation tools, but it should be
your fallback path.
</p>
</div>
<span className='rounded-full bg-white/15 px-4 py-2 text-xs font-black uppercase tracking-widest text-white ring-1 ring-white/20'>
Webhook setup recommended
</span>
</div>
</div>
<div className='mt-5 grid gap-4 lg:grid-cols-2 2xl:grid-cols-3'>
{providerOptions.map((provider) => {
const setup = providerSetupDetails[provider.key];
<div className='grid gap-4 p-4 lg:grid-cols-[1fr_0.95fr]'>
<div className='space-y-4 rounded-2xl bg-slate-50 p-4 dark:bg-dark-800'>
<div>
<p className='mb-1 text-xs font-bold uppercase tracking-widest text-slate-400'>
Dashboard path
</p>
<p className='font-semibold text-slate-900 dark:text-white'>
{selectedSetup.dashboardPath}
</p>
</div>
return (
<div
key={`${provider.key}-setup-guide`}
className='overflow-hidden rounded-2xl border border-slate-200 bg-slate-50 dark:border-dark-700 dark:bg-dark-800'
>
<div
className={`bg-gradient-to-r ${providerGradient[provider.key] || providerGradient.stripe} p-4 text-white`}
>
<p className='text-xs font-bold uppercase tracking-[0.2em] text-white/70'>
{provider.label}
</p>
<h5 className='text-lg font-black'>Webhook setup</h5>
</div>
<div className='space-y-4 p-4 text-sm text-slate-600 dark:text-slate-300'>
<div>
<p className='mb-1 text-xs font-bold uppercase tracking-widest text-slate-400'>
Dashboard path
</p>
<p className='font-semibold text-slate-900 dark:text-white'>
{setup.dashboardPath}
</p>
</div>
<div>
<p className='mb-2 text-xs font-bold uppercase tracking-widest text-slate-400'>
Install steps
</p>
<ol className='list-decimal space-y-2 pl-4 text-sm leading-6 text-slate-600 dark:text-slate-300'>
{selectedSetup.steps.map((step, index) => (
<li key={`${selectedProvider.key}-selected-step-${index}`}>
{step}
</li>
))}
</ol>
</div>
<div>
<p className='mb-2 text-xs font-bold uppercase tracking-widest text-slate-400'>
Install steps
</p>
<ol className='list-decimal space-y-2 pl-4'>
{setup.steps.map((step, index) => (
<li key={`${provider.key}-step-${index}`}>{step}</li>
))}
</ol>
</div>
<div>
<p className='mb-2 text-xs font-bold uppercase tracking-widest text-slate-400'>
Events to enable
</p>
<div className='flex flex-wrap gap-2'>
{setup.requiredEvents.map((eventName) => (
<code
key={`${provider.key}-${eventName}`}
className='rounded-lg bg-slate-950 px-2 py-1 text-xs text-emerald-200'
>
{eventName}
</code>
))}
</div>
</div>
<div className='rounded-xl bg-white p-3 text-xs leading-5 ring-1 ring-slate-200 dark:bg-dark-900 dark:ring-dark-700'>
<strong>Test after saving:</strong> {setup.testTip}
</div>
</div>
<div>
<p className='mb-2 text-xs font-bold uppercase tracking-widest text-slate-400'>
Events to enable
</p>
<div className='flex flex-wrap gap-2'>
{selectedSetup.requiredEvents.map((eventName) => (
<code
key={`${selectedProvider.key}-${eventName}`}
className='rounded-lg bg-slate-950 px-2 py-1 text-xs text-emerald-200'
>
{eventName}
</code>
))}
</div>
);
})}
</div>
<div className='rounded-xl bg-white p-3 text-xs leading-5 text-slate-600 ring-1 ring-slate-200 dark:bg-dark-900 dark:text-slate-300 dark:ring-dark-700'>
<strong>Test after saving:</strong> {selectedSetup.testTip}
</div>
</div>
<div className='space-y-4 rounded-2xl border border-dashed border-slate-300 bg-white p-4 dark:border-dark-700 dark:bg-dark-900'>
<div>
<p className='text-xs font-bold uppercase tracking-[0.25em] text-amber-500'>
API backup
</p>
<h5 className='mt-1 text-xl font-black text-slate-900 dark:text-white'>
Backup POST option for {selectedProvider.label}
</h5>
<p className='mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300'>
{selectedApiBackup.summary}
</p>
</div>
<div>
<p className='mb-2 text-xs font-bold uppercase tracking-widest text-slate-400'>
When to use this backup
</p>
<ul className='list-disc space-y-2 pl-4 text-sm leading-6 text-slate-600 dark:text-slate-300'>
{commonApiBackupUseCases.map((useCase) => (
<li key={useCase}>{useCase}</li>
))}
</ul>
</div>
<div>
<p className='mb-2 text-xs font-bold uppercase tracking-widest text-slate-400'>
Backup endpoint
</p>
{selectedWebhookTargets.length > 0 ? (
<div className='space-y-2'>
{selectedWebhookTargets.map((target) => (
<div
key={`${target.businessId}-${selectedProvider.key}-backup-url`}
className='rounded-xl bg-slate-50 p-3 ring-1 ring-slate-200 dark:bg-dark-800 dark:ring-dark-700'
>
<p className='mb-1 text-xs font-black uppercase tracking-widest text-slate-400'>
POST · {target.businessName}
</p>
<code className='block break-all rounded-lg bg-slate-950 p-3 text-xs leading-5 text-emerald-200'>
{target.url}
</code>
<BaseButton
type='button'
icon={
copiedUrl === target.url
? mdiCheckCircleOutline
: mdiContentCopy
}
label={copiedUrl === target.url ? 'Copied' : 'Copy URL'}
color='info'
small
className='mt-2'
onClick={() => copyWebhookUrl(target.url)}
/>
</div>
))}
</div>
) : (
<code className='block rounded-xl bg-slate-950 p-3 text-xs leading-5 text-emerald-200'>
Connect {selectedProvider.label} first, then copy the
generated webhook URL here. The API backup uses that same URL.
</code>
)}
</div>
<div>
<p className='mb-2 text-xs font-bold uppercase tracking-widest text-slate-400'>
Headers
</p>
<div className='space-y-2 text-sm text-slate-600 dark:text-slate-300'>
<code className='block rounded-lg bg-slate-100 px-3 py-2 dark:bg-dark-800'>
Content-Type: application/json
</code>
<p>
No user login token is required for this public webhook URL;
the secret token in the URL protects it.
</p>
</div>
</div>
<div>
<p className='mb-2 text-xs font-bold uppercase tracking-widest text-slate-400'>
Example JSON body
</p>
<pre className='max-h-80 overflow-auto rounded-xl bg-slate-950 p-3 text-xs leading-5 text-emerald-200'>
<code>{selectedApiBackup.samplePayload}</code>
</pre>
</div>
<div className='rounded-xl bg-amber-50 p-3 text-xs leading-5 text-amber-900 ring-1 ring-amber-200 dark:bg-amber-950/30 dark:text-amber-100 dark:ring-amber-900'>
<strong>Backup success check:</strong>{' '}
{selectedApiBackup.successTip}
</div>
</div>
</div>
</div>
@ -876,7 +1192,9 @@ export default function PaymentProviderConnectors({
{business.name}
</h4>
<p className='text-sm text-slate-500'>
Default review delay: {business.delay_days ?? 0} days · Review destination: {business.review_destination || 'google'}
Default review delay: {business.delay_days ?? 0} days ·
Review destination:{' '}
{business.review_destination || 'google'}
</p>
</div>
<BaseButton
@ -956,7 +1274,7 @@ export default function PaymentProviderConnectors({
</div>
<div className='rounded-xl bg-white p-3 text-xs text-slate-600 ring-1 ring-slate-200 dark:bg-dark-900 dark:text-slate-300 dark:ring-dark-700'>
<p className='mb-2 font-black text-slate-900 dark:text-white'>
Provider setup steps
Quick setup reminder
</p>
<ol className='list-decimal space-y-1 pl-4'>
{(providerInstructions[provider.key] || []).map(
@ -965,6 +1283,11 @@ export default function PaymentProviderConnectors({
),
)}
</ol>
<p className='mt-3 text-slate-500 dark:text-slate-400'>
API backup: if the dashboard webhook cannot be used,
POST the matching provider-style JSON to this same
URL.
</p>
{provider.webhook_token_last4 && (
<p className='mt-3 text-slate-400'>
Secret token ends in: ****

View File

@ -14,6 +14,12 @@ const menuAside: MenuAsideItem[] = [
label: 'Review Flow',
},
{
href: '/subscription',
icon: icon.mdiCreditCardOutline,
label: 'Subscription',
},
{
href: '/users/users-list',
label: 'Users',

View File

@ -1,4 +1,4 @@
import { mdiArrowRight, mdiChartTimelineVariant, mdiCheckCircleOutline, mdiLogin, mdiShieldCheckOutline, mdiStarCircleOutline } from '@mdi/js';
import { mdiArrowRight, mdiCheckCircleOutline, mdiLogin, mdiShieldCheckOutline, mdiStarCircleOutline } from '@mdi/js';
import Head from 'next/head';
import Link from 'next/link';
import React, { ReactElement } from 'react';
@ -6,6 +6,7 @@ import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
import { subscriptionPlans, trialDays } from '../subscriptionPlans';
const metrics = [
['7 days', 'default review delay'],
@ -46,6 +47,7 @@ export default function Starter() {
<span className="text-xl">Review Flow</span>
</Link>
<nav className="flex items-center gap-3">
<BaseButton href="/#pricing" label="Pricing" color="whiteDark" />
<BaseButton href="/login" icon={mdiLogin} label="Login" color="whiteDark" />
<BaseButton href="/reviewflow" icon={mdiArrowRight} label="Admin interface" color="info" />
</nav>
@ -134,6 +136,94 @@ export default function Starter() {
</div>
</section>
<section id="pricing" className="px-6 pb-20">
<div className="mx-auto max-w-7xl">
<div className="mx-auto max-w-3xl text-center">
<p className="text-sm font-black uppercase tracking-[0.3em] text-emerald-600">Simple pricing</p>
<h2 className="mt-4 text-4xl font-black tracking-tight text-slate-950 md:text-5xl">Choose Starter or Pro.</h2>
<p className="mt-5 text-lg leading-8 text-slate-600">
Every plan starts with a {trialDays}-day free trial. Starter covers the core review workflow. Pro adds the advanced automation and reputation marketing tools growing teams need.
</p>
</div>
<div className="mt-12 grid gap-6 lg:grid-cols-2">
{subscriptionPlans.map((plan) => {
const isPro = plan.id === 'pro';
return (
<CardBox
key={plan.id}
className={`relative overflow-hidden border-0 bg-white shadow-2xl ${
isPro ? 'shadow-indigo-950/20 ring-2 ring-indigo-600' : 'shadow-slate-200/70 ring-1 ring-slate-200'
}`}
cardBoxClassName="p-0"
>
{plan.highlight && (
<div className="absolute right-6 top-6 rounded-full bg-indigo-600 px-4 py-1 text-sm font-black text-white shadow-lg shadow-indigo-600/30">
{plan.highlight}
</div>
)}
<div className="p-8">
<p className="text-sm font-black uppercase tracking-[0.3em] text-slate-400">Review Flow</p>
<h3 className="mt-3 text-3xl font-black text-slate-950">{plan.name}</h3>
<p className="mt-3 min-h-[56px] leading-7 text-slate-600">{plan.tagline}</p>
<div className="mt-8 flex items-end gap-2">
<span className="text-5xl font-black tracking-tight text-slate-950">${plan.priceMonthly}</span>
<span className="pb-2 font-bold text-slate-500">/month</span>
</div>
<p className="mt-2 text-sm font-bold text-emerald-600">{plan.trialDays}-day free trial included</p>
<div className="mt-8 grid gap-3 rounded-3xl bg-slate-50 p-5 sm:grid-cols-2">
<div>
<p className="text-2xl font-black text-slate-950">{plan.limits.monthlyReviewRequests.toLocaleString()}</p>
<p className="text-sm text-slate-500">review requests/month</p>
</div>
<div>
<p className="text-2xl font-black text-slate-950">{plan.limits.businesses}</p>
<p className="text-sm text-slate-500">businesses/locations</p>
</div>
<div>
<p className="text-2xl font-black text-slate-950">{plan.limits.teamMembers}</p>
<p className="text-sm text-slate-500">team members</p>
</div>
<div>
<p className="text-2xl font-black text-slate-950">{plan.limits.paymentConnectors}</p>
<p className="text-sm text-slate-500">payment connectors</p>
</div>
</div>
<BaseButton
href={`/register?plan=${plan.id}`}
icon={mdiArrowRight}
label={plan.ctaLabel}
color={isPro ? 'info' : 'whiteDark'}
className="mt-8 w-full shadow-xl shadow-indigo-600/10"
/>
</div>
<div className={isPro ? 'bg-indigo-950 p-8 text-white' : 'bg-slate-950 p-8 text-white'}>
<p className="mb-5 text-sm font-black uppercase tracking-[0.25em] text-emerald-300">Included features</p>
<div className="grid gap-3">
{plan.features.map((feature) => (
<div key={feature} className="flex items-start gap-3">
<span className="mt-1 text-emerald-300">
<svg className="h-5 w-5" viewBox="0 0 24 24">
<path fill="currentColor" d={mdiCheckCircleOutline} />
</svg>
</span>
<span className="font-semibold text-slate-100">{feature}</span>
</div>
))}
</div>
</div>
</CardBox>
);
})}
</div>
</div>
</section>
<section className="bg-[#101828] px-6 py-20 text-white">
<div className="mx-auto grid max-w-7xl gap-10 lg:grid-cols-[0.8fr_1.2fr] lg:items-center">
<div>

View File

@ -62,58 +62,34 @@ export default function Login() {
'Transportation teams can manage businesses, customers, transactions, payment events, and review requests without jumping tools.',
},
{
title: 'More value in the base plan',
title: 'Clear Starter and Pro tiers',
description:
'The $65 Base plan includes review automation, widgets, social proof, analytics, AI replies, referrals, and the app tools already available.',
'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: 'Base',
price: '$65',
name: 'Starter',
price: '$49',
description:
'Best for businesses that want full review growth tools plus the core Review Flow admin system.',
'Best for small teams that need the core Review Flow workflow and simple monthly limits.',
sections: [
{
title: 'Review automation',
features: [
'Automate review requests and follow-up reminders.',
'Manually send review requests.',
'Personalize review request SMS and email messaging.',
'Personalize review invite links.',
'Monitor reviews across the web.',
'New review notifications and opportunities reports.',
],
},
{
title: 'Widgets, referrals, and social proof',
features: [
'Showcase reviews on your website with social proof widgets.',
'Collect reviews and leads with widgets for your website.',
'Microsite that showcases your reviews and generates leads.',
'Automate sharing of reviews to your social media accounts.',
'Share referral link on social media.',
],
},
{
title: 'Insights, AI, and team motivation',
features: [
'Easily respond to customer reviews with AI-generated replies.',
'Gain review insights and trending topics.',
'Campaign insights and analytics.',
'Encourage friendly competition with staff leaderboards.',
'Connect to 1000s of business apps.',
],
},
{
title: 'Existing Review Flow tools included',
title: 'Core review workflow',
features: [
'Review Flow workspace for creating, scheduling, and tracking review requests.',
'Business, customer, transaction, and delivery follow-up records.',
'Webhook connectors for Stripe, PayPal, Square, Shopify, and WooCommerce workflows.',
'Payment events, email delivery logs, and cron run monitoring.',
'Admin dashboard with users, roles, permissions, profile, and API documentation access.',
'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.',
],
},
],
@ -122,46 +98,23 @@ export default function Login() {
name: 'Pro',
price: '$99',
description:
'Best for growing teams that want every Base feature plus booking, referral, gifting, competitor, and advanced AI tools.',
'Best for growing teams that want higher limits, automation, AI assistance, and reputation marketing tools.',
sections: [
{
title: 'Everything in Base',
title: 'Everything in Starter',
features: [
'Includes all Base review automation, widgets, referrals, analytics, AI replies, social sharing, integrations, and existing app tools.',
'Advanced workflow management.',
'Priority setup support.',
'2,500 review requests per month.',
'10 businesses or locations.',
'10 team members.',
'Priority support and advanced reporting.',
],
},
{
title: 'Booking reminders',
title: 'Growth tools',
features: [
'Automate repeat booking reminders and follow-ups.',
'Personalize booking reminder SMS and email messaging.',
],
},
{
title: 'Referral automation',
features: [
'Automate customer referral requests and follow-ups.',
'Personalize referral request SMS and email messaging.',
'Personalize referral invite links.',
],
},
{
title: 'Gifting and loyalty',
features: [
'Delight your loyal customers with gift automations.',
'Automate gifting for new customers.',
],
},
{
title: 'Competitor intelligence and advanced feedback',
features: [
'Gain competitor review and SEO insights.',
'Track competitor topics and gain valuable competitive intel.',
'Competitor topic insights include topics for your business.',
'Automate review replies with AI.',
'Collect deeper, more actionable customer feedback with NPS Surveys.',
'Advanced automation rules.',
'AI review reply assistant.',
'Social proof widgets, referral campaigns, repeat booking reminders, NPS surveys, and broadcasts.',
],
},
],

View File

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

View File

@ -93,6 +93,29 @@ interface SummaryResponse {
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',
@ -122,6 +145,12 @@ const statusStyles: Record<string, string> = {
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';
@ -165,6 +194,8 @@ export default function ReviewFlowWorkspace() {
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 ?? [];
@ -215,6 +246,17 @@ export default function ReviewFlowWorkspace() {
}
};
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);
@ -224,6 +266,7 @@ export default function ReviewFlowWorkspace() {
}
loadSummary();
loadSubscriptionStatus();
}, []);
const updateForm = (key: keyof typeof defaultForm, value: string) => {
@ -251,7 +294,7 @@ export default function ReviewFlowWorkspace() {
customerEmail: '',
phone: '',
}));
await loadSummary();
await Promise.all([loadSummary(), loadSubscriptionStatus()]);
} catch (requestError) {
console.error('Failed to create review request:', requestError);
if (axios.isAxiosError(requestError) && requestError.response?.data) {
@ -277,9 +320,28 @@ export default function ReviewFlowWorkspace() {
reviewLink: connectorForm.reviewLink,
delayDays: connectorForm.delayDays,
}));
await loadSummary();
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>
@ -333,6 +395,48 @@ export default function ReviewFlowWorkspace() {
</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
@ -341,7 +445,16 @@ export default function ReviewFlowWorkspace() {
)}
{error && (
<div className='mb-6 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-rose-900'>
{error}
<p>{error}</p>
{error.includes('Upgrade to Pro') && (
<BaseButton
href='/subscription'
icon={mdiCreditCardOutline}
label='Manage subscription'
color='danger'
className='mt-3'
/>
)}
</div>
)}
@ -350,6 +463,39 @@ export default function ReviewFlowWorkspace() {
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'>

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

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