diff --git a/backend/src/db/api/users.js b/backend/src/db/api/users.js index 66b9f08..9376dab 100644 --- a/backend/src/db/api/users.js +++ b/backend/src/db/api/users.js @@ -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 }, diff --git a/backend/src/db/migrations/20260629065000-add-user-subscriptions.js b/backend/src/db/migrations/20260629065000-add-user-subscriptions.js new file mode 100644 index 0000000..803676f --- /dev/null +++ b/backend/src/db/migrations/20260629065000-add-user-subscriptions.js @@ -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; + } + }, +}; diff --git a/backend/src/db/models/users.js b/backend/src/db/models/users.js index 0f93416..a4f2e27 100644 --- a/backend/src/db/models/users.js +++ b/backend/src/db/models/users.js @@ -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, diff --git a/backend/src/index.js b/backend/src/index.js index 2c62141..3960c95 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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'); diff --git a/backend/src/routes/plans.js b/backend/src/routes/plans.js new file mode 100644 index 0000000..b150c9a --- /dev/null +++ b/backend/src/routes/plans.js @@ -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; diff --git a/backend/src/routes/reviewflow.js b/backend/src/routes/reviewflow.js index 25f2fdd..63045f2 100644 --- a/backend/src/routes/reviewflow.js +++ b/backend/src/routes/reviewflow.js @@ -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 diff --git a/backend/src/routes/subscription.js b/backend/src/routes/subscription.js new file mode 100644 index 0000000..1b522c2 --- /dev/null +++ b/backend/src/routes/subscription.js @@ -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; diff --git a/backend/src/services/auth.js b/backend/src/services/auth.js index 2862da4..de1b061 100644 --- a/backend/src/services/auth.js +++ b/backend/src/services/auth.js @@ -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, diff --git a/backend/src/services/businesses.js b/backend/src/services/businesses.js index 80bd8bb..3150585 100644 --- a/backend/src/services/businesses.js +++ b/backend/src/services/businesses.js @@ -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, diff --git a/backend/src/services/review_requests.js b/backend/src/services/review_requests.js index d0709ab..1d259df 100644 --- a/backend/src/services/review_requests.js +++ b/backend/src/services/review_requests.js @@ -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, diff --git a/backend/src/services/reviewflow.js b/backend/src/services/reviewflow.js index 34fad8b..bfe83f4 100644 --- a/backend/src/services/reviewflow.js +++ b/backend/src/services/reviewflow.js @@ -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.'; } diff --git a/backend/src/services/subscription.js b/backend/src/services/subscription.js new file mode 100644 index 0000000..8bed1ca --- /dev/null +++ b/backend/src/services/subscription.js @@ -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; + } +}; diff --git a/backend/src/services/subscriptionPlans.js b/backend/src/services/subscriptionPlans.js new file mode 100644 index 0000000..a244087 --- /dev/null +++ b/backend/src/services/subscriptionPlans.js @@ -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, +}; diff --git a/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx b/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx index e3b50d2..353eecd 100644 --- a/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx +++ b/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx @@ -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 = { + 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 = { 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 - 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. @@ -620,36 +829,47 @@ export default function PaymentProviderConnectors({ ))} -
- {providerOptions.map((provider) => { - const isSelected = connectorForm.provider === provider.key; - - return ( - - ); - })} +
+

+ Cleaner setup flow +

+

+ Choose one provider, then follow one guide +

+

+ 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. +

+
+
+

+ 1. Select provider +

+

+ Pick Stripe, PayPal, Square, Shopify, or WooCommerce from the + dropdown. +

+
+
+

+ 2. Connect webhook +

+

+ Connect first so Review Flow generates the secure URL for that + provider. +

+
+
+

+ 3. Use API backup if needed +

+

+ If dashboard webhooks are unavailable, POST provider-style JSON to + the same URL. +

+
+
-
-
-

- Installation guide -

-

- How to install provider webhooks -

-

- 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. -

+
+
+
+
+

+ Selected provider guide +

+

+ {selectedProvider.label} setup +

+

+ Follow the webhook instructions first. API backup is available + below for custom systems or automation tools, but it should be + your fallback path. +

+
+ + Webhook setup — recommended + +
-
- {providerOptions.map((provider) => { - const setup = providerSetupDetails[provider.key]; +
+
+
+

+ Dashboard path +

+

+ {selectedSetup.dashboardPath} +

+
- return ( -
-
-

- {provider.label} -

-
Webhook setup
-
-
-
-

- Dashboard path -

-

- {setup.dashboardPath} -

-
+
+

+ Install steps +

+
    + {selectedSetup.steps.map((step, index) => ( +
  1. + {step} +
  2. + ))} +
+
-
-

- Install steps -

-
    - {setup.steps.map((step, index) => ( -
  1. {step}
  2. - ))} -
-
- -
-

- Events to enable -

-
- {setup.requiredEvents.map((eventName) => ( - - {eventName} - - ))} -
-
- -
- Test after saving: {setup.testTip} -
-
+
+

+ Events to enable +

+
+ {selectedSetup.requiredEvents.map((eventName) => ( + + {eventName} + + ))}
- ); - })} +
+ +
+ Test after saving: {selectedSetup.testTip} +
+
+ +
+
+

+ API backup +

+
+ Backup POST option for {selectedProvider.label} +
+

+ {selectedApiBackup.summary} +

+
+ +
+

+ When to use this backup +

+
    + {commonApiBackupUseCases.map((useCase) => ( +
  • {useCase}
  • + ))} +
+
+ +
+

+ Backup endpoint +

+ {selectedWebhookTargets.length > 0 ? ( +
+ {selectedWebhookTargets.map((target) => ( +
+

+ POST · {target.businessName} +

+ + {target.url} + + copyWebhookUrl(target.url)} + /> +
+ ))} +
+ ) : ( + + Connect {selectedProvider.label} first, then copy the + generated webhook URL here. The API backup uses that same URL. + + )} +
+ +
+

+ Headers +

+
+ + Content-Type: application/json + +

+ No user login token is required for this public webhook URL; + the secret token in the URL protects it. +

+
+
+ +
+

+ Example JSON body +

+
+                {selectedApiBackup.samplePayload}
+              
+
+ +
+ Backup success check:{' '} + {selectedApiBackup.successTip} +
+
@@ -876,7 +1192,9 @@ export default function PaymentProviderConnectors({ {business.name}

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

- Provider setup steps + Quick setup reminder

    {(providerInstructions[provider.key] || []).map( @@ -965,6 +1283,11 @@ export default function PaymentProviderConnectors({ ), )}
+

+ API backup: if the dashboard webhook cannot be used, + POST the matching provider-style JSON to this same + URL. +

{provider.webhook_token_last4 && (

Secret token ends in: **** diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 6ea41b5..87e167f 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -14,6 +14,12 @@ const menuAside: MenuAsideItem[] = [ label: 'Review Flow', }, + { + href: '/subscription', + icon: icon.mdiCreditCardOutline, + label: 'Subscription', + }, + { href: '/users/users-list', label: 'Users', diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 72520c7..dda5ad9 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -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() { Review Flow

@@ -134,6 +136,94 @@ export default function Starter() {
+
+
+
+

Simple pricing

+

Choose Starter or Pro.

+

+ 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. +

+
+ +
+ {subscriptionPlans.map((plan) => { + const isPro = plan.id === 'pro'; + + return ( + + {plan.highlight && ( +
+ {plan.highlight} +
+ )} +
+

Review Flow

+

{plan.name}

+

{plan.tagline}

+ +
+ ${plan.priceMonthly} + /month +
+

{plan.trialDays}-day free trial included

+ +
+
+

{plan.limits.monthlyReviewRequests.toLocaleString()}

+

review requests/month

+
+
+

{plan.limits.businesses}

+

businesses/locations

+
+
+

{plan.limits.teamMembers}

+

team members

+
+
+

{plan.limits.paymentConnectors}

+

payment connectors

+
+
+ + +
+ +
+

Included features

+
+ {plan.features.map((feature) => ( +
+ + + + + + {feature} +
+ ))} +
+
+
+ ); + })} +
+
+
+
diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index 3a3403c..ce9da92 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -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.', ], }, ], diff --git a/frontend/src/pages/register.tsx b/frontend/src/pages/register.tsx index 73a3987..d83e001 100644 --- a/frontend/src/pages/register.tsx +++ b/frontend/src/pages/register.tsx @@ -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() { +
+

{selectedPlan.name} trial

+

${selectedPlan.priceMonthly}/month after the {selectedPlan.trialDays}-day free trial. You can change plans from Subscription after signup.

+
= { 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(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 ( <> @@ -333,6 +395,48 @@ export default function ReviewFlowWorkspace() {
+ {currentSubscription && currentUsage && currentLimits && ( +
+
+
+

+ Plan and usage +

+

+ {currentSubscription.planName} · {currentSubscription.effectiveStatus} +

+

+ {currentSubscription.trialDaysLeft !== null && + currentSubscription.trialDaysLeft !== undefined + ? `${currentSubscription.trialDaysLeft} trial days left. ` + : ''} + {reviewRequestsRemaining.toLocaleString()} review requests and {businessesRemaining.toLocaleString()} business slots remaining on this plan. +

+
+
+
+
+ Monthly review requests + {reviewRequestsUsed.toLocaleString()} / {reviewRequestsLimit.toLocaleString()} +
+
+
= 80 ? 'h-full rounded-full bg-amber-500' : 'h-full rounded-full bg-emerald-500'} + style={{ width: `${reviewRequestsPercent}%` }} + /> +
+
+ +
+
+
+ )} + {created && (
Review request queued. {created.customer?.email} is @@ -341,7 +445,16 @@ export default function ReviewFlowWorkspace() { )} {error && (
- {error} +

{error}

+ {error.includes('Upgrade to Pro') && ( + + )}
)} @@ -350,6 +463,39 @@ export default function ReviewFlowWorkspace() { onConnected={handleProviderConnected} /> + {isStarterPlan && ( + +
+
+

+ Pro upgrade prompts +

+

+ Unlock advanced reputation growth tools. +

+

+ Starter keeps the core review workflow running. Pro raises limits and unlocks the next automation, AI, and marketing modules as they are enabled. +

+ +
+
+ {proFeaturePrompts.map(([title, copy]) => ( +
+

{title}

+

{copy}

+
+ ))} +
+
+
+ )} +
diff --git a/frontend/src/pages/subscription.tsx b/frontend/src/pages/subscription.tsx new file mode 100644 index 0000000..faf9c9c --- /dev/null +++ b/frontend/src/pages/subscription.tsx @@ -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(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 ( + <> + + {getPageTitle('Subscription')} + + + + + + + {message && ( +
+ {message} +
+ )} + {error && ( +
+ {error} +
+ )} + + {isLoading && !status ? ( + Loading subscription details... + ) : status ? ( + <> + +
+
+

+ Current plan +

+

+ {status.subscription.planName} +

+

+ Status: {status.subscription.effectiveStatus}. Trial ends {formatDate(status.subscription.trialEndsAt)} + {status.subscription.trialDaysLeft !== null && status.subscription.trialDaysLeft !== undefined + ? ` (${status.subscription.trialDaysLeft} days left)` + : ''} + . +

+
+
+

Monthly price

+

${status.subscription.priceMonthly}

+

per month after trial

+
+
+
+ +
+ {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 ( + +
+

{item.label}

+

+ {formatLimit(used)} / {formatLimit(limit)} +

+
+
+
+
+ + ) + })} +
+ +
+ {status.plans.map((plan) => { + const isCurrent = currentPlanId === plan.id + const isPro = plan.id === 'pro' + + return ( + + {isPro && ( +
+ Pro growth tools +
+ )} +
+

Review Flow

+

{plan.name}

+

{plan.tagline}

+
+ ${plan.priceMonthly} + /month +
+
+
+

{formatLimit(plan.limits.monthlyReviewRequests)}

+

requests/month

+
+
+

{formatLimit(plan.limits.businesses)}

+

businesses

+
+
+

{formatLimit(plan.limits.teamMembers)}

+

team members

+
+
+

{formatLimit(plan.limits.paymentConnectors)}

+

connectors

+
+
+ selectPlan(plan.id)} + /> +
+
+

Included

+
+ {plan.features.map((feature) => ( +
+ + {feature} +
+ ))} +
+
+
+ ) + })} +
+ + ) : null} + + + ) +} + +SubscriptionPage.getLayout = function getLayout(page: ReactElement) { + return {page} +} diff --git a/frontend/src/subscriptionPlans.ts b/frontend/src/subscriptionPlans.ts new file mode 100644 index 0000000..6abf76d --- /dev/null +++ b/frontend/src/subscriptionPlans.ts @@ -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', + ], + }, +];