Autosave: 20260629-071644
This commit is contained in:
parent
df9c6cb725
commit
3dfd47bae8
@ -793,6 +793,13 @@ module.exports = class UsersDBApi {
|
||||
firstName: data.firstName,
|
||||
authenticationUid: data.authenticationUid,
|
||||
password: data.password,
|
||||
subscriptionPlanId: data.subscriptionPlanId || 'starter',
|
||||
subscriptionStatus: data.subscriptionStatus || 'trialing',
|
||||
trialStartedAt: data.trialStartedAt || null,
|
||||
trialEndsAt: data.trialEndsAt || null,
|
||||
subscriptionStartedAt: data.subscriptionStartedAt || null,
|
||||
subscriptionEndsAt: data.subscriptionEndsAt || null,
|
||||
subscriptionCanceledAt: data.subscriptionCanceledAt || null,
|
||||
|
||||
},
|
||||
{ transaction },
|
||||
|
||||
@ -0,0 +1,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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
|
||||
12
backend/src/routes/plans.js
Normal file
12
backend/src/routes/plans.js
Normal file
@ -0,0 +1,12 @@
|
||||
const express = require('express');
|
||||
const { getSubscriptionPlans } = require('../services/subscriptionPlans');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
res.status(200).send({
|
||||
plans: getSubscriptionPlans(),
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@ -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
|
||||
|
||||
21
backend/src/routes/subscription.js
Normal file
21
backend/src/routes/subscription.js
Normal file
@ -0,0 +1,21 @@
|
||||
const express = require('express');
|
||||
const SubscriptionService = require('../services/subscription');
|
||||
const wrapAsync = require('../helpers').wrapAsync;
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/me', wrapAsync(async (req, res) => {
|
||||
const status = await SubscriptionService.getStatus(req.currentUser);
|
||||
|
||||
res.status(200).send(status);
|
||||
}));
|
||||
|
||||
router.post('/select-plan', wrapAsync(async (req, res) => {
|
||||
const status = await SubscriptionService.selectPlan(req.currentUser, req.body?.planId || req.body?.plan);
|
||||
|
||||
res.status(200).send(status);
|
||||
}));
|
||||
|
||||
router.use('/', require('../helpers').commonErrorHandler);
|
||||
|
||||
module.exports = router;
|
||||
@ -1,4 +1,5 @@
|
||||
const UsersDBApi = require('../db/api/users');
|
||||
const db = require('../db/models');
|
||||
const ValidationError = require('./notifications/errors/validation');
|
||||
const ForbiddenError = require('./notifications/errors/forbidden');
|
||||
const bcrypt = require('bcrypt');
|
||||
@ -8,6 +9,7 @@ const PasswordResetEmail = require('./email/list/passwordReset');
|
||||
const EmailSender = require('./email');
|
||||
const config = require('../config');
|
||||
const helpers = require('../helpers');
|
||||
const SubscriptionService = require('./subscription');
|
||||
|
||||
class Auth {
|
||||
static async signup(email, password, options = {}, host) {
|
||||
@ -54,11 +56,16 @@ class Auth {
|
||||
return helpers.jwtSign(data);
|
||||
}
|
||||
|
||||
const subscriptionPayload = SubscriptionService.getSignupSubscriptionPayload(
|
||||
options?.body?.planId || options?.body?.plan,
|
||||
);
|
||||
|
||||
const newUser = await UsersDBApi.createFromAuth(
|
||||
{
|
||||
firstName: email.split('@')[0],
|
||||
password: hashedPassword,
|
||||
email: email,
|
||||
...subscriptionPayload,
|
||||
|
||||
},
|
||||
options,
|
||||
|
||||
@ -6,6 +6,7 @@ const csv = require('csv-parser');
|
||||
const axios = require('axios');
|
||||
const config = require('../config');
|
||||
const stream = require('stream');
|
||||
const SubscriptionService = require('./subscription');
|
||||
|
||||
|
||||
|
||||
@ -15,6 +16,8 @@ module.exports = class BusinessesService {
|
||||
static async create(data, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
await SubscriptionService.assertCanCreateBusinesses(currentUser, 1, { transaction });
|
||||
|
||||
await BusinessesDBApi.create(
|
||||
data,
|
||||
{
|
||||
@ -51,6 +54,8 @@ module.exports = class BusinessesService {
|
||||
.on('error', (error) => reject(error));
|
||||
})
|
||||
|
||||
await SubscriptionService.assertCanCreateBusinesses(req.currentUser, results.length, { transaction });
|
||||
|
||||
await BusinessesDBApi.bulkImport(results, {
|
||||
transaction,
|
||||
ignoreDuplicates: true,
|
||||
|
||||
@ -6,6 +6,7 @@ const csv = require('csv-parser');
|
||||
const axios = require('axios');
|
||||
const config = require('../config');
|
||||
const stream = require('stream');
|
||||
const SubscriptionService = require('./subscription');
|
||||
|
||||
|
||||
|
||||
@ -15,6 +16,8 @@ module.exports = class Review_requestsService {
|
||||
static async create(data, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
await SubscriptionService.assertCanCreateReviewRequests(currentUser, 1, { transaction });
|
||||
|
||||
await Review_requestsDBApi.create(
|
||||
data,
|
||||
{
|
||||
@ -51,6 +54,8 @@ module.exports = class Review_requestsService {
|
||||
.on('error', (error) => reject(error));
|
||||
})
|
||||
|
||||
await SubscriptionService.assertCanCreateReviewRequests(req.currentUser, results.length, { transaction });
|
||||
|
||||
await Review_requestsDBApi.bulkImport(results, {
|
||||
transaction,
|
||||
ignoreDuplicates: true,
|
||||
|
||||
@ -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.';
|
||||
}
|
||||
|
||||
|
||||
325
backend/src/services/subscription.js
Normal file
325
backend/src/services/subscription.js
Normal file
@ -0,0 +1,325 @@
|
||||
const db = require('../db/models');
|
||||
const {
|
||||
TRIAL_DAYS,
|
||||
getSubscriptionPlanById,
|
||||
getSubscriptionPlans,
|
||||
} = require('./subscriptionPlans');
|
||||
|
||||
const DEFAULT_PLAN_ID = 'starter';
|
||||
const DEFAULT_STATUS = 'trialing';
|
||||
const DAY_IN_MS = 24 * 60 * 60 * 1000;
|
||||
const PAYMENT_CONNECTOR_FIELDS = [
|
||||
'stripe_connected',
|
||||
'square_connected',
|
||||
'paypal_connected',
|
||||
'shopify_connected',
|
||||
'woocommerce_connected',
|
||||
];
|
||||
|
||||
function httpError(message, code = 403) {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function normalizePlanId(planId) {
|
||||
const normalized = typeof planId === 'string' ? planId.trim().toLowerCase() : '';
|
||||
|
||||
return getSubscriptionPlanById(normalized) ? normalized : DEFAULT_PLAN_ID;
|
||||
}
|
||||
|
||||
function getPlan(planId) {
|
||||
return getSubscriptionPlanById(normalizePlanId(planId)) || getSubscriptionPlanById(DEFAULT_PLAN_ID);
|
||||
}
|
||||
|
||||
function addDays(date, days) {
|
||||
return new Date(date.getTime() + days * DAY_IN_MS);
|
||||
}
|
||||
|
||||
function buildTrialWindow(referenceDate = new Date()) {
|
||||
const trialStartedAt = new Date(referenceDate);
|
||||
|
||||
return {
|
||||
trialStartedAt,
|
||||
trialEndsAt: addDays(trialStartedAt, TRIAL_DAYS),
|
||||
};
|
||||
}
|
||||
|
||||
function getCurrentMonthRange(referenceDate = new Date()) {
|
||||
const periodStart = new Date(Date.UTC(
|
||||
referenceDate.getUTCFullYear(),
|
||||
referenceDate.getUTCMonth(),
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
));
|
||||
const periodEnd = new Date(Date.UTC(
|
||||
referenceDate.getUTCFullYear(),
|
||||
referenceDate.getUTCMonth() + 1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
));
|
||||
|
||||
return { periodStart, periodEnd };
|
||||
}
|
||||
|
||||
function toDateOrNull(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function getEffectiveSubscription(user, referenceDate = new Date()) {
|
||||
const plan = getPlan(user?.subscriptionPlanId);
|
||||
const status = user?.subscriptionStatus || DEFAULT_STATUS;
|
||||
const trialStartedAt = toDateOrNull(user?.trialStartedAt);
|
||||
const trialEndsAt = toDateOrNull(user?.trialEndsAt);
|
||||
const isTrialActive = status === 'trialing' && (!trialEndsAt || trialEndsAt.getTime() >= referenceDate.getTime());
|
||||
const isActive = status === 'active' || isTrialActive;
|
||||
const effectiveStatus = status === 'trialing' && !isTrialActive ? 'expired' : status;
|
||||
const trialDaysLeft = trialEndsAt
|
||||
? Math.max(0, Math.ceil((trialEndsAt.getTime() - referenceDate.getTime()) / DAY_IN_MS))
|
||||
: null;
|
||||
|
||||
return {
|
||||
plan,
|
||||
planId: plan.id,
|
||||
status,
|
||||
effectiveStatus,
|
||||
isActive,
|
||||
trialStartedAt,
|
||||
trialEndsAt,
|
||||
trialDaysLeft,
|
||||
};
|
||||
}
|
||||
|
||||
function getLimitMessage(plan, usageCount, limit, unit, resetDate) {
|
||||
return `${plan.name} includes ${limit.toLocaleString()} ${unit}. You have already used ${usageCount.toLocaleString()}. Upgrade to Pro or wait until ${resetDate.toISOString().slice(0, 10)} for the monthly limit to reset.`;
|
||||
}
|
||||
|
||||
async function getUserRecord(currentUserOrId, options = {}) {
|
||||
const transaction = options.transaction || undefined;
|
||||
const userId = typeof currentUserOrId === 'string' ? currentUserOrId : currentUserOrId?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw httpError('A signed-in user is required to check subscription limits.', 403);
|
||||
}
|
||||
|
||||
const shouldLoad = typeof currentUserOrId === 'string' || currentUserOrId.subscriptionPlanId === undefined;
|
||||
|
||||
if (!shouldLoad) {
|
||||
return currentUserOrId;
|
||||
}
|
||||
|
||||
const user = await db.users.findByPk(userId, { transaction });
|
||||
|
||||
if (!user) {
|
||||
throw httpError('Subscription user was not found.', 404);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
module.exports = class SubscriptionService {
|
||||
static normalizePlanId(planId) {
|
||||
return normalizePlanId(planId);
|
||||
}
|
||||
|
||||
static getSignupSubscriptionPayload(planId) {
|
||||
return {
|
||||
subscriptionPlanId: normalizePlanId(planId),
|
||||
subscriptionStatus: DEFAULT_STATUS,
|
||||
...buildTrialWindow(),
|
||||
};
|
||||
}
|
||||
|
||||
static getEffectiveSubscription(user, referenceDate = new Date()) {
|
||||
return getEffectiveSubscription(user, referenceDate);
|
||||
}
|
||||
|
||||
static async getUsageForUserId(userId, options = {}) {
|
||||
const transaction = options.transaction || undefined;
|
||||
const { periodStart, periodEnd } = getCurrentMonthRange();
|
||||
const businesses = await db.businesses.findAll({
|
||||
where: { createdById: userId },
|
||||
attributes: ['id', ...PAYMENT_CONNECTOR_FIELDS],
|
||||
transaction,
|
||||
});
|
||||
const monthlyReviewRequests = await db.review_requests.count({
|
||||
where: {
|
||||
createdById: userId,
|
||||
createdAt: {
|
||||
[db.Sequelize.Op.gte]: periodStart,
|
||||
[db.Sequelize.Op.lt]: periodEnd,
|
||||
},
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
const paymentConnectors = businesses.reduce((total, business) => {
|
||||
return total + PAYMENT_CONNECTOR_FIELDS.filter((field) => Boolean(business[field])).length;
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
monthlyReviewRequests,
|
||||
businesses: businesses.length,
|
||||
teamMembers: 1,
|
||||
paymentConnectors,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
};
|
||||
}
|
||||
|
||||
static async getStatus(currentUserOrId, options = {}) {
|
||||
const user = await getUserRecord(currentUserOrId, options);
|
||||
const subscription = getEffectiveSubscription(user);
|
||||
const usage = await this.getUsageForUserId(user.id, options);
|
||||
const plans = getSubscriptionPlans();
|
||||
|
||||
return {
|
||||
subscription: {
|
||||
planId: subscription.planId,
|
||||
planName: subscription.plan.name,
|
||||
status: subscription.status,
|
||||
effectiveStatus: subscription.effectiveStatus,
|
||||
isActive: subscription.isActive,
|
||||
trialStartedAt: subscription.trialStartedAt,
|
||||
trialEndsAt: subscription.trialEndsAt,
|
||||
trialDaysLeft: subscription.trialDaysLeft,
|
||||
priceMonthly: subscription.plan.priceMonthly,
|
||||
currency: subscription.plan.currency,
|
||||
},
|
||||
plan: subscription.plan,
|
||||
usage,
|
||||
limits: subscription.plan.limits,
|
||||
plans,
|
||||
};
|
||||
}
|
||||
|
||||
static async selectPlan(currentUser, planId) {
|
||||
const user = await db.users.findByPk(currentUser?.id);
|
||||
|
||||
if (!user) {
|
||||
throw httpError('Subscription user was not found.', 404);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const existingSubscription = getEffectiveSubscription(user, now);
|
||||
const needsNewTrial = existingSubscription.effectiveStatus === 'expired' || !user.trialStartedAt || !user.trialEndsAt;
|
||||
const trialWindow = needsNewTrial ? buildTrialWindow(now) : {
|
||||
trialStartedAt: user.trialStartedAt,
|
||||
trialEndsAt: user.trialEndsAt,
|
||||
};
|
||||
|
||||
await user.update({
|
||||
subscriptionPlanId: normalizePlanId(planId),
|
||||
subscriptionStatus: user.subscriptionStatus === 'active' ? 'active' : DEFAULT_STATUS,
|
||||
trialStartedAt: trialWindow.trialStartedAt,
|
||||
trialEndsAt: trialWindow.trialEndsAt,
|
||||
updatedById: currentUser.id,
|
||||
});
|
||||
|
||||
return this.getStatus(user.id);
|
||||
}
|
||||
|
||||
static async canCreateReviewRequests(currentUserOrId, quantity = 1, options = {}) {
|
||||
const user = await getUserRecord(currentUserOrId, options);
|
||||
const subscription = getEffectiveSubscription(user);
|
||||
|
||||
if (!subscription.isActive) {
|
||||
return {
|
||||
allowed: false,
|
||||
code: 403,
|
||||
message: 'Your Review Flow trial has ended. Choose a plan to keep creating review requests.',
|
||||
};
|
||||
}
|
||||
|
||||
const usage = await this.getUsageForUserId(user.id, options);
|
||||
const limit = subscription.plan.limits.monthlyReviewRequests;
|
||||
|
||||
if (usage.monthlyReviewRequests + quantity > limit) {
|
||||
return {
|
||||
allowed: false,
|
||||
code: 403,
|
||||
message: getLimitMessage(
|
||||
subscription.plan,
|
||||
usage.monthlyReviewRequests,
|
||||
limit,
|
||||
'review requests per month',
|
||||
usage.periodEnd,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true, usage, subscription };
|
||||
}
|
||||
|
||||
static async assertCanCreateReviewRequests(currentUserOrId, quantity = 1, options = {}) {
|
||||
const result = await this.canCreateReviewRequests(currentUserOrId, quantity, options);
|
||||
|
||||
if (!result.allowed) {
|
||||
throw httpError(result.message, result.code);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static async canCreateBusinesses(currentUserOrId, quantity = 1, options = {}) {
|
||||
const user = await getUserRecord(currentUserOrId, options);
|
||||
const subscription = getEffectiveSubscription(user);
|
||||
|
||||
if (!subscription.isActive) {
|
||||
return {
|
||||
allowed: false,
|
||||
code: 403,
|
||||
message: 'Your Review Flow trial has ended. Choose a plan to keep adding businesses.',
|
||||
};
|
||||
}
|
||||
|
||||
const usage = await this.getUsageForUserId(user.id, options);
|
||||
const limit = subscription.plan.limits.businesses;
|
||||
|
||||
if (usage.businesses + quantity > limit) {
|
||||
return {
|
||||
allowed: false,
|
||||
code: 403,
|
||||
message: getLimitMessage(subscription.plan, usage.businesses, limit, 'businesses/locations', usage.periodEnd),
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true, usage, subscription };
|
||||
}
|
||||
|
||||
static async assertCanCreateBusinesses(currentUserOrId, quantity = 1, options = {}) {
|
||||
const result = await this.canCreateBusinesses(currentUserOrId, quantity, options);
|
||||
|
||||
if (!result.allowed) {
|
||||
throw httpError(result.message, result.code);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static async assertFeatureAccess(currentUserOrId, featureKey, options = {}) {
|
||||
const user = await getUserRecord(currentUserOrId, options);
|
||||
const subscription = getEffectiveSubscription(user);
|
||||
|
||||
if (!subscription.isActive) {
|
||||
throw httpError('Your Review Flow trial has ended. Choose a plan to keep using this feature.', 403);
|
||||
}
|
||||
|
||||
if (!subscription.plan.includedFeatureKeys.includes(featureKey)) {
|
||||
throw httpError(`${subscription.plan.name} does not include this feature. Upgrade to Pro to unlock it.`, 403);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
113
backend/src/services/subscriptionPlans.js
Normal file
113
backend/src/services/subscriptionPlans.js
Normal file
@ -0,0 +1,113 @@
|
||||
const TRIAL_DAYS = 14;
|
||||
|
||||
const subscriptionPlans = [
|
||||
{
|
||||
id: 'starter',
|
||||
name: 'Starter',
|
||||
priceMonthly: 49,
|
||||
currency: 'USD',
|
||||
trialDays: TRIAL_DAYS,
|
||||
tagline: 'For small teams that want automated review collection without extra marketing automation.',
|
||||
limits: {
|
||||
monthlyReviewRequests: 250,
|
||||
businesses: 1,
|
||||
teamMembers: 2,
|
||||
paymentConnectors: 5,
|
||||
},
|
||||
features: [
|
||||
'Review Flow dashboard',
|
||||
'Manual review request creation',
|
||||
'Hosted public review form',
|
||||
'Customer management',
|
||||
'Business/location management',
|
||||
'Transaction tracking',
|
||||
'Stripe, Square, PayPal, Shopify, and WooCommerce webhook intake',
|
||||
'Review request status tracking',
|
||||
'Email delivery logs',
|
||||
'Basic reporting',
|
||||
'Standard support',
|
||||
],
|
||||
includedFeatureKeys: [
|
||||
'reviewflow_dashboard',
|
||||
'manual_review_requests',
|
||||
'hosted_review_form',
|
||||
'customer_management',
|
||||
'business_management',
|
||||
'transaction_tracking',
|
||||
'payment_webhooks',
|
||||
'review_status_tracking',
|
||||
'email_delivery_logs',
|
||||
'basic_reporting',
|
||||
'standard_support',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pro',
|
||||
name: 'Pro',
|
||||
priceMonthly: 99,
|
||||
currency: 'USD',
|
||||
trialDays: TRIAL_DAYS,
|
||||
tagline: 'For growing businesses that want automation, AI assistance, and reputation marketing tools.',
|
||||
limits: {
|
||||
monthlyReviewRequests: 2500,
|
||||
businesses: 10,
|
||||
teamMembers: 10,
|
||||
paymentConnectors: 5,
|
||||
},
|
||||
features: [
|
||||
'Everything in Starter',
|
||||
'Advanced automation rules',
|
||||
'AI review reply assistant',
|
||||
'Social proof widgets',
|
||||
'Review monitoring workspace',
|
||||
'Referral campaigns',
|
||||
'Repeat booking reminders',
|
||||
'NPS surveys',
|
||||
'Competitor/reputation insights',
|
||||
'Broadcast campaigns',
|
||||
'Advanced reporting',
|
||||
'Branding customization',
|
||||
'Priority support',
|
||||
],
|
||||
includedFeatureKeys: [
|
||||
'reviewflow_dashboard',
|
||||
'manual_review_requests',
|
||||
'hosted_review_form',
|
||||
'customer_management',
|
||||
'business_management',
|
||||
'transaction_tracking',
|
||||
'payment_webhooks',
|
||||
'review_status_tracking',
|
||||
'email_delivery_logs',
|
||||
'basic_reporting',
|
||||
'standard_support',
|
||||
'advanced_automation',
|
||||
'ai_review_replies',
|
||||
'social_proof_widgets',
|
||||
'review_monitoring',
|
||||
'referral_campaigns',
|
||||
'repeat_booking_reminders',
|
||||
'nps_surveys',
|
||||
'competitor_insights',
|
||||
'broadcast_campaigns',
|
||||
'advanced_reporting',
|
||||
'branding_customization',
|
||||
'priority_support',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const getSubscriptionPlans = () => subscriptionPlans.map((plan) => ({
|
||||
...plan,
|
||||
limits: { ...plan.limits },
|
||||
features: [...plan.features],
|
||||
includedFeatureKeys: [...plan.includedFeatureKeys],
|
||||
}));
|
||||
|
||||
const getSubscriptionPlanById = (planId) => getSubscriptionPlans().find((plan) => plan.id === planId);
|
||||
|
||||
module.exports = {
|
||||
TRIAL_DAYS,
|
||||
getSubscriptionPlanById,
|
||||
getSubscriptionPlans,
|
||||
};
|
||||
@ -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: ****
|
||||
|
||||
@ -14,6 +14,12 @@ const menuAside: MenuAsideItem[] = [
|
||||
label: 'Review Flow',
|
||||
},
|
||||
|
||||
{
|
||||
href: '/subscription',
|
||||
icon: icon.mdiCreditCardOutline,
|
||||
label: 'Subscription',
|
||||
},
|
||||
|
||||
{
|
||||
href: '/users/users-list',
|
||||
label: 'Users',
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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.',
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@ -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: '',
|
||||
|
||||
@ -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'>
|
||||
|
||||
275
frontend/src/pages/subscription.tsx
Normal file
275
frontend/src/pages/subscription.tsx
Normal file
@ -0,0 +1,275 @@
|
||||
import {
|
||||
mdiArrowUpBoldCircleOutline,
|
||||
mdiCheckCircleOutline,
|
||||
mdiCreditCardOutline,
|
||||
mdiRefresh,
|
||||
} from '@mdi/js'
|
||||
import axios from 'axios'
|
||||
import Head from 'next/head'
|
||||
import React, { ReactElement, useEffect, useState } from 'react'
|
||||
import BaseButton from '../components/BaseButton'
|
||||
import CardBox from '../components/CardBox'
|
||||
import SectionMain from '../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
|
||||
import LayoutAuthenticated from '../layouts/Authenticated'
|
||||
import { getPageTitle } from '../config'
|
||||
import { SubscriptionPlan } from '../subscriptionPlans'
|
||||
|
||||
type SubscriptionStatusResponse = {
|
||||
subscription: {
|
||||
planId: string
|
||||
planName: string
|
||||
status: string
|
||||
effectiveStatus: string
|
||||
isActive: boolean
|
||||
trialEndsAt?: string | null
|
||||
trialDaysLeft?: number | null
|
||||
priceMonthly: number
|
||||
currency: string
|
||||
}
|
||||
usage: {
|
||||
monthlyReviewRequests: number
|
||||
businesses: number
|
||||
teamMembers: number
|
||||
paymentConnectors: number
|
||||
periodStart?: string
|
||||
periodEnd?: string
|
||||
}
|
||||
limits: SubscriptionPlan['limits']
|
||||
plans: SubscriptionPlan[]
|
||||
}
|
||||
|
||||
const usageLabels: Array<{
|
||||
key: keyof SubscriptionStatusResponse['usage']
|
||||
limitKey: keyof SubscriptionPlan['limits']
|
||||
label: string
|
||||
}> = [
|
||||
{ key: 'monthlyReviewRequests', limitKey: 'monthlyReviewRequests', label: 'Review requests this month' },
|
||||
{ key: 'businesses', limitKey: 'businesses', label: 'Businesses / locations' },
|
||||
{ key: 'teamMembers', limitKey: 'teamMembers', label: 'Team members' },
|
||||
{ key: 'paymentConnectors', limitKey: 'paymentConnectors', label: 'Connected payment providers' },
|
||||
]
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
if (!value) return 'Not set'
|
||||
|
||||
return new Intl.DateTimeFormat('en', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
function formatLimit(value: number) {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
export default function SubscriptionPage() {
|
||||
const [status, setStatus] = useState<SubscriptionStatusResponse | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [selectingPlanId, setSelectingPlanId] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const loadStatus = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await axios.get('/subscription/me')
|
||||
setStatus(response.data)
|
||||
setError('')
|
||||
} catch (requestError) {
|
||||
console.error('Failed to load subscription status:', requestError)
|
||||
setError('Could not load your subscription status. Please refresh and try again.')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadStatus()
|
||||
}, [])
|
||||
|
||||
const selectPlan = async (planId: string) => {
|
||||
setSelectingPlanId(planId)
|
||||
setError('')
|
||||
setMessage('')
|
||||
|
||||
try {
|
||||
const response = await axios.post('/subscription/select-plan', { planId })
|
||||
setStatus(response.data)
|
||||
setMessage(`Your trial plan is now ${response.data.subscription.planName}.`)
|
||||
} catch (requestError) {
|
||||
console.error('Failed to select subscription plan:', requestError)
|
||||
if (axios.isAxiosError(requestError) && requestError.response?.data) {
|
||||
setError(String(requestError.response.data))
|
||||
} else {
|
||||
setError('Could not update your plan. Please try again.')
|
||||
}
|
||||
} finally {
|
||||
setSelectingPlanId('')
|
||||
}
|
||||
}
|
||||
|
||||
const currentPlanId = status?.subscription.planId
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Subscription')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton
|
||||
icon={mdiCreditCardOutline}
|
||||
title='Subscription and limits'
|
||||
main
|
||||
>
|
||||
<BaseButton
|
||||
icon={mdiRefresh}
|
||||
label='Refresh'
|
||||
color='whiteDark'
|
||||
onClick={loadStatus}
|
||||
/>
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
{message && (
|
||||
<div className='mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-emerald-900'>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className='mb-6 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-rose-900'>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && !status ? (
|
||||
<CardBox>Loading subscription details...</CardBox>
|
||||
) : status ? (
|
||||
<>
|
||||
<CardBox className='mb-6 overflow-hidden border-0 bg-gradient-to-br from-slate-950 via-indigo-950 to-emerald-950 text-white shadow-2xl'>
|
||||
<div className='grid gap-6 lg:grid-cols-[1fr_0.8fr] lg:items-center'>
|
||||
<div>
|
||||
<p className='text-sm font-black uppercase tracking-[0.3em] text-emerald-300'>
|
||||
Current plan
|
||||
</p>
|
||||
<h2 className='mt-3 text-4xl font-black tracking-tight md:text-5xl'>
|
||||
{status.subscription.planName}
|
||||
</h2>
|
||||
<p className='mt-3 max-w-2xl text-slate-200'>
|
||||
Status: <strong>{status.subscription.effectiveStatus}</strong>. Trial ends {formatDate(status.subscription.trialEndsAt)}
|
||||
{status.subscription.trialDaysLeft !== null && status.subscription.trialDaysLeft !== undefined
|
||||
? ` (${status.subscription.trialDaysLeft} days left)`
|
||||
: ''}
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<div className='rounded-3xl bg-white/10 p-6 ring-1 ring-white/15'>
|
||||
<p className='text-sm font-bold text-slate-300'>Monthly price</p>
|
||||
<p className='mt-2 text-5xl font-black'>${status.subscription.priceMonthly}</p>
|
||||
<p className='mt-1 text-sm text-slate-300'>per month after trial</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<div className='mb-6 grid gap-6 lg:grid-cols-2'>
|
||||
{usageLabels.map((item) => {
|
||||
const used = Number(status.usage[item.key]) || 0
|
||||
const limit = Number(status.limits[item.limitKey]) || 1
|
||||
const percent = Math.min(100, Math.round((used / limit) * 100))
|
||||
const isNearLimit = percent >= 80
|
||||
|
||||
return (
|
||||
<CardBox key={item.key} className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
|
||||
<div className='mb-3 flex items-center justify-between gap-3'>
|
||||
<p className='font-black text-slate-900 dark:text-white'>{item.label}</p>
|
||||
<p className={isNearLimit ? 'font-black text-amber-600' : 'font-black text-emerald-600'}>
|
||||
{formatLimit(used)} / {formatLimit(limit)}
|
||||
</p>
|
||||
</div>
|
||||
<div className='h-3 overflow-hidden rounded-full bg-slate-100 dark:bg-dark-800'>
|
||||
<div
|
||||
className={isNearLimit ? 'h-full rounded-full bg-amber-500' : 'h-full rounded-full bg-emerald-500'}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</CardBox>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className='grid gap-6 lg:grid-cols-2'>
|
||||
{status.plans.map((plan) => {
|
||||
const isCurrent = currentPlanId === plan.id
|
||||
const isPro = plan.id === 'pro'
|
||||
|
||||
return (
|
||||
<CardBox
|
||||
key={plan.id}
|
||||
className={`relative overflow-hidden border-0 shadow-2xl ${isPro ? 'ring-2 ring-indigo-600' : 'ring-1 ring-slate-200 dark:ring-dark-700'}`}
|
||||
cardBoxClassName='p-0'
|
||||
>
|
||||
{isPro && (
|
||||
<div className='absolute right-6 top-6 rounded-full bg-indigo-600 px-4 py-1 text-sm font-black text-white'>
|
||||
Pro growth tools
|
||||
</div>
|
||||
)}
|
||||
<div className='p-8'>
|
||||
<p className='text-sm font-black uppercase tracking-[0.3em] text-slate-400'>Review Flow</p>
|
||||
<h3 className='mt-3 text-3xl font-black text-slate-900 dark:text-white'>{plan.name}</h3>
|
||||
<p className='mt-3 min-h-[56px] leading-7 text-slate-500 dark:text-slate-400'>{plan.tagline}</p>
|
||||
<div className='mt-8 flex items-end gap-2'>
|
||||
<span className='text-5xl font-black tracking-tight text-slate-900 dark:text-white'>${plan.priceMonthly}</span>
|
||||
<span className='pb-2 font-bold text-slate-500'>/month</span>
|
||||
</div>
|
||||
<div className='mt-8 grid gap-3 rounded-3xl bg-slate-50 p-5 dark:bg-dark-800 sm:grid-cols-2'>
|
||||
<div>
|
||||
<p className='text-2xl font-black'>{formatLimit(plan.limits.monthlyReviewRequests)}</p>
|
||||
<p className='text-sm text-slate-500'>requests/month</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-2xl font-black'>{formatLimit(plan.limits.businesses)}</p>
|
||||
<p className='text-sm text-slate-500'>businesses</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-2xl font-black'>{formatLimit(plan.limits.teamMembers)}</p>
|
||||
<p className='text-sm text-slate-500'>team members</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-2xl font-black'>{formatLimit(plan.limits.paymentConnectors)}</p>
|
||||
<p className='text-sm text-slate-500'>connectors</p>
|
||||
</div>
|
||||
</div>
|
||||
<BaseButton
|
||||
icon={isCurrent ? mdiCheckCircleOutline : mdiArrowUpBoldCircleOutline}
|
||||
label={isCurrent ? 'Current plan' : `Switch trial to ${plan.name}`}
|
||||
color={isCurrent ? 'success' : isPro ? 'info' : 'whiteDark'}
|
||||
className='mt-8 w-full'
|
||||
disabled={isCurrent || Boolean(selectingPlanId)}
|
||||
onClick={() => selectPlan(plan.id)}
|
||||
/>
|
||||
</div>
|
||||
<div className={isPro ? 'bg-indigo-950 p-8 text-white' : 'bg-slate-950 p-8 text-white'}>
|
||||
<p className='mb-5 text-sm font-black uppercase tracking-[0.25em] text-emerald-300'>Included</p>
|
||||
<div className='grid gap-3'>
|
||||
{plan.features.map((feature) => (
|
||||
<div key={feature} className='flex items-start gap-3'>
|
||||
<span className='mt-1 text-emerald-300'>✓</span>
|
||||
<span className='font-semibold text-slate-100'>{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</SectionMain>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
SubscriptionPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
|
||||
}
|
||||
79
frontend/src/subscriptionPlans.ts
Normal file
79
frontend/src/subscriptionPlans.ts
Normal file
@ -0,0 +1,79 @@
|
||||
export type SubscriptionPlan = {
|
||||
id: 'starter' | 'pro';
|
||||
name: string;
|
||||
priceMonthly: number;
|
||||
currency: 'USD';
|
||||
trialDays: number;
|
||||
tagline: string;
|
||||
highlight?: string;
|
||||
ctaLabel: string;
|
||||
limits: {
|
||||
monthlyReviewRequests: number;
|
||||
businesses: number;
|
||||
teamMembers: number;
|
||||
paymentConnectors: number;
|
||||
};
|
||||
features: string[];
|
||||
};
|
||||
|
||||
export const trialDays = 14;
|
||||
|
||||
export const subscriptionPlans: SubscriptionPlan[] = [
|
||||
{
|
||||
id: 'starter',
|
||||
name: 'Starter',
|
||||
priceMonthly: 49,
|
||||
currency: 'USD',
|
||||
trialDays,
|
||||
tagline: 'For small businesses that want automated review collection up and running quickly.',
|
||||
ctaLabel: 'Start Starter trial',
|
||||
limits: {
|
||||
monthlyReviewRequests: 250,
|
||||
businesses: 1,
|
||||
teamMembers: 2,
|
||||
paymentConnectors: 5,
|
||||
},
|
||||
features: [
|
||||
'Review Flow dashboard',
|
||||
'Manual review request creation',
|
||||
'Hosted public review form',
|
||||
'Customer and transaction management',
|
||||
'Stripe, Square, PayPal, Shopify, and WooCommerce webhook intake',
|
||||
'Review request status tracking',
|
||||
'Email delivery logs',
|
||||
'Basic reporting',
|
||||
'Standard support',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pro',
|
||||
name: 'Pro',
|
||||
priceMonthly: 99,
|
||||
currency: 'USD',
|
||||
trialDays,
|
||||
tagline: 'For growing teams that want advanced automation, AI assistance, and reputation marketing tools.',
|
||||
highlight: 'Best value',
|
||||
ctaLabel: 'Start Pro trial',
|
||||
limits: {
|
||||
monthlyReviewRequests: 2500,
|
||||
businesses: 10,
|
||||
teamMembers: 10,
|
||||
paymentConnectors: 5,
|
||||
},
|
||||
features: [
|
||||
'Everything in Starter',
|
||||
'Advanced automation rules',
|
||||
'AI review reply assistant',
|
||||
'Social proof widgets',
|
||||
'Review monitoring workspace',
|
||||
'Referral campaigns',
|
||||
'Repeat booking reminders',
|
||||
'NPS surveys',
|
||||
'Competitor/reputation insights',
|
||||
'Broadcast campaigns',
|
||||
'Advanced reporting',
|
||||
'Branding customization',
|
||||
'Priority support',
|
||||
],
|
||||
},
|
||||
];
|
||||
Loading…
x
Reference in New Issue
Block a user