From 30e91e2b175364d139e0c8789ce0c32b9f1b4431 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Mon, 29 Jun 2026 22:43:42 +0000 Subject: [PATCH] Autosave: 20260629-224348 --- backend/.env | 3 + backend/src/db/api/businesses.js | 190 ++++- backend/src/db/api/users.js | 83 +- ...add-business-type-and-growth-automation.js | 89 +++ backend/src/db/models/businesses.js | 91 +++ .../20260629143000-pro-demo-customer.js | 387 +++++++++ backend/src/routes/reviewflow-public.js | 66 ++ backend/src/routes/reviewflow.js | 184 ++++- backend/src/routes/users.js | 16 +- backend/src/services/reviewflow.js | 509 ++++++++++++ backend/src/services/subscriptionPlans.js | 73 +- backend/src/services/users.js | 2 +- .../ReviewFlow/PaymentProviderConnectors.tsx | 173 +++- frontend/src/components/SelectField.tsx | 16 +- frontend/src/components/SelectFieldMany.tsx | 16 +- frontend/src/menuAside.ts | 11 + .../src/pages/businesses/[businessesId].tsx | 17 +- .../src/pages/businesses/businesses-edit.tsx | 17 +- .../src/pages/businesses/businesses-list.tsx | 2 +- .../src/pages/businesses/businesses-new.tsx | 15 + .../src/pages/businesses/businesses-table.tsx | 2 +- frontend/src/pages/growth-tools.tsx | 755 ++++++++++++++++++ frontend/src/pages/index.tsx | 8 +- frontend/src/pages/login.tsx | 44 +- frontend/src/pages/reviewflow.tsx | 575 +++++++++---- frontend/src/pages/subscription.tsx | 2 +- frontend/src/pages/users/[usersId].tsx | 1 - frontend/src/pages/users/users-edit.tsx | 1 - frontend/src/pages/users/users-list.tsx | 1 - frontend/src/pages/users/users-new.tsx | 94 ++- frontend/src/pages/users/users-table.tsx | 1 - frontend/src/pages/users/users-view.tsx | 1 - frontend/src/subscriptionPlans.ts | 48 +- 33 files changed, 3158 insertions(+), 335 deletions(-) create mode 100644 backend/src/db/migrations/20260629120000-add-business-type-and-growth-automation.js create mode 100644 backend/src/db/seeders/20260629143000-pro-demo-customer.js create mode 100644 frontend/src/pages/growth-tools.tsx diff --git a/backend/.env b/backend/.env index 25241b7..3910891 100644 --- a/backend/.env +++ b/backend/.env @@ -1 +1,4 @@ PORT=8080 +TWILIO_ACCOUNT_SID=ACf0b6dd3d34b2aefffd9914c317bf04e0 +TWILIO_AUTH_TOKEN=5b4dc2c0246b699596997a212a46548a +TWILIO_FROM_NUMBER=+17372324091 diff --git a/backend/src/db/api/businesses.js b/backend/src/db/api/businesses.js index 6cae658..c0f1cf9 100644 --- a/backend/src/db/api/businesses.js +++ b/backend/src/db/api/businesses.js @@ -1,7 +1,5 @@ const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); const Utils = require('../utils'); @@ -26,6 +24,82 @@ module.exports = class BusinessesDBApi { null , + business_type: data.business_type + || + 'hybrid' + , + + automation_mode: data.automation_mode + || + 'set_and_forget' + , + + followup_enabled: data.followup_enabled !== undefined ? data.followup_enabled : true + , + + followup_delay_days: data.followup_delay_days + || + 3 + , + + max_followups: data.max_followups + || + 1 + , + + ai_reply_enabled: data.ai_reply_enabled + || + false + , + + referral_enabled: data.referral_enabled + || + false + , + + referral_offer: data.referral_offer + || + null + , + + nps_enabled: data.nps_enabled + || + false + , + + nps_question: data.nps_question + || + null + , + + social_widget_enabled: data.social_widget_enabled !== undefined ? data.social_widget_enabled : true + , + + broadcast_enabled: data.broadcast_enabled + || + false + , + + rebooking_enabled: data.rebooking_enabled + || + false + , + + competitor_insights_enabled: data.competitor_insights_enabled + || + false + , + + competitor_urls: data.competitor_urls + || + null + , + + review_widget_theme: data.review_widget_theme + || + 'light' + , + google_review_link: data.google_review_link || null @@ -120,6 +194,82 @@ module.exports = class BusinessesDBApi { name: item.name || null + , + + business_type: item.business_type + || + 'hybrid' + , + + automation_mode: item.automation_mode + || + 'set_and_forget' + , + + followup_enabled: item.followup_enabled !== undefined ? item.followup_enabled : true + , + + followup_delay_days: item.followup_delay_days + || + 3 + , + + max_followups: item.max_followups + || + 1 + , + + ai_reply_enabled: item.ai_reply_enabled + || + false + , + + referral_enabled: item.referral_enabled + || + false + , + + referral_offer: item.referral_offer + || + null + , + + nps_enabled: item.nps_enabled + || + false + , + + nps_question: item.nps_question + || + null + , + + social_widget_enabled: item.social_widget_enabled !== undefined ? item.social_widget_enabled : true + , + + broadcast_enabled: item.broadcast_enabled + || + false + , + + rebooking_enabled: item.rebooking_enabled + || + false + , + + competitor_insights_enabled: item.competitor_insights_enabled + || + false + , + + competitor_urls: item.competitor_urls + || + null + , + + review_widget_theme: item.review_widget_theme + || + 'light' , google_review_link: item.google_review_link @@ -214,6 +364,39 @@ module.exports = class BusinessesDBApi { if (data.name !== undefined) updatePayload.name = data.name; + if (data.business_type !== undefined) updatePayload.business_type = data.business_type; + + if (data.automation_mode !== undefined) updatePayload.automation_mode = data.automation_mode; + + if (data.followup_enabled !== undefined) updatePayload.followup_enabled = data.followup_enabled; + + if (data.followup_delay_days !== undefined) updatePayload.followup_delay_days = data.followup_delay_days; + + if (data.max_followups !== undefined) updatePayload.max_followups = data.max_followups; + + if (data.ai_reply_enabled !== undefined) updatePayload.ai_reply_enabled = data.ai_reply_enabled; + + if (data.referral_enabled !== undefined) updatePayload.referral_enabled = data.referral_enabled; + + if (data.referral_offer !== undefined) updatePayload.referral_offer = data.referral_offer; + + if (data.nps_enabled !== undefined) updatePayload.nps_enabled = data.nps_enabled; + + if (data.nps_question !== undefined) updatePayload.nps_question = data.nps_question; + + if (data.social_widget_enabled !== undefined) updatePayload.social_widget_enabled = data.social_widget_enabled; + + if (data.broadcast_enabled !== undefined) updatePayload.broadcast_enabled = data.broadcast_enabled; + + if (data.rebooking_enabled !== undefined) updatePayload.rebooking_enabled = data.rebooking_enabled; + + if (data.competitor_insights_enabled !== undefined) updatePayload.competitor_insights_enabled = data.competitor_insights_enabled; + + if (data.competitor_urls !== undefined) updatePayload.competitor_urls = data.competitor_urls; + + if (data.review_widget_theme !== undefined) updatePayload.review_widget_theme = data.review_widget_theme; + + if (data.google_review_link !== undefined) updatePayload.google_review_link = data.google_review_link; @@ -384,9 +567,6 @@ module.exports = class BusinessesDBApi { offset = currentPage * limit; - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; let include = [ diff --git a/backend/src/db/api/users.js b/backend/src/db/api/users.js index 9376dab..a0482fc 100644 --- a/backend/src/db/api/users.js +++ b/backend/src/db/api/users.js @@ -12,6 +12,47 @@ const config = require('../../config'); const Sequelize = db.Sequelize; const Op = Sequelize.Op; +const INTERNAL_ADMIN_ROLE_NAMES = ['Administrator']; + +function getRoleName(currentUser) { + return currentUser?.app_role?.name || currentUser?.app_role?.dataValues?.name || ''; +} + +function isInternalAdminUser(currentUser) { + return currentUser?.email === 'admin@flatlogic.com' || INTERNAL_ADMIN_ROLE_NAMES.includes(getRoleName(currentUser)); +} + +function getTeamOwnerId(currentUser) { + return currentUser?.createdById || currentUser?.id || null; +} + +function applyCurrentUserScope(where, currentUser) { + if (!currentUser || isInternalAdminUser(currentUser)) { + return where; + } + + const teamOwnerId = getTeamOwnerId(currentUser); + + if (!teamOwnerId) { + return where; + } + + const teamScope = { + [Op.or]: [ + { id: teamOwnerId }, + { createdById: teamOwnerId }, + ], + }; + + if (!where || Reflect.ownKeys(where).length === 0) { + return teamScope; + } + + return { + [Op.and]: [where, teamScope], + }; +} + module.exports = class UsersDBApi { static async create(data, options) { @@ -95,9 +136,16 @@ module.exports = class UsersDBApi { if (!data.data.app_role) { - const role = await db.roles.findOne({ - where: { name: 'User' }, + const defaultRoleNames = isInternalAdminUser(currentUser) + ? [config.roles?.user || 'User'] + : ['Operations Manager', 'Account Owner']; + const roles = await db.roles.findAll({ + where: { name: { [Op.in]: defaultRoleNames } }, + transaction, }); + const role = defaultRoleNames + .map((roleName) => roles.find((candidate) => candidate.name === roleName)) + .find(Boolean); if (role) { await users.setApp_role(role, { transaction, @@ -237,7 +285,10 @@ module.exports = class UsersDBApi { const transaction = (options && options.transaction) || undefined; - const users = await db.users.findByPk(id, {}, {transaction}); + const users = await db.users.findOne({ + where: applyCurrentUserScope({ id }, currentUser), + transaction, + }); @@ -342,11 +393,11 @@ module.exports = class UsersDBApi { const transaction = (options && options.transaction) || undefined; const users = await db.users.findAll({ - where: { + where: applyCurrentUserScope({ id: { [Op.in]: ids, }, - }, + }, currentUser), transaction, }); @@ -370,7 +421,10 @@ module.exports = class UsersDBApi { const currentUser = (options && options.currentUser) || {id: null}; const transaction = (options && options.transaction) || undefined; - const users = await db.users.findByPk(id, options); + const users = await db.users.findOne({ + where: applyCurrentUserScope({ id }, currentUser), + transaction, + }); await users.update({ deletedBy: currentUser.id @@ -388,10 +442,10 @@ module.exports = class UsersDBApi { static async findBy(where, options) { const transaction = (options && options.transaction) || undefined; - const users = await db.users.findOne( - { where }, - { transaction }, - ); + const users = await db.users.findOne({ + where: applyCurrentUserScope(where, options?.currentUser), + transaction, + }); if (!users) { return users; @@ -455,9 +509,6 @@ module.exports = class UsersDBApi { offset = currentPage * limit; - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; let include = [ @@ -723,6 +774,8 @@ module.exports = class UsersDBApi { + where = applyCurrentUserScope(where, options?.currentUser); + const queryOptions = { where, include, @@ -752,7 +805,7 @@ module.exports = class UsersDBApi { } } - static async findAllAutocomplete(query, limit, offset, ) { + static async findAllAutocomplete(query, limit, offset, options = {}) { let where = {}; @@ -770,6 +823,8 @@ module.exports = class UsersDBApi { }; } + where = applyCurrentUserScope(where, options?.currentUser); + const records = await db.users.findAll({ attributes: [ 'id', 'firstName' ], where, diff --git a/backend/src/db/migrations/20260629120000-add-business-type-and-growth-automation.js b/backend/src/db/migrations/20260629120000-add-business-type-and-growth-automation.js new file mode 100644 index 0000000..75d8fd6 --- /dev/null +++ b/backend/src/db/migrations/20260629120000-add-business-type-and-growth-automation.js @@ -0,0 +1,89 @@ +'use strict'; + +const businessColumns = { + business_type: { type: 'TEXT', defaultValue: 'hybrid', allowNull: false }, + automation_mode: { type: 'TEXT', defaultValue: 'set_and_forget', allowNull: false }, + followup_enabled: { type: 'BOOLEAN', defaultValue: true, allowNull: false }, + followup_delay_days: { type: 'INTEGER', defaultValue: 3, allowNull: false }, + max_followups: { type: 'INTEGER', defaultValue: 1, allowNull: false }, + ai_reply_enabled: { type: 'BOOLEAN', defaultValue: false, allowNull: false }, + referral_enabled: { type: 'BOOLEAN', defaultValue: false, allowNull: false }, + referral_offer: { type: 'TEXT' }, + nps_enabled: { type: 'BOOLEAN', defaultValue: false, allowNull: false }, + nps_question: { type: 'TEXT' }, + social_widget_enabled: { type: 'BOOLEAN', defaultValue: true, allowNull: false }, + broadcast_enabled: { type: 'BOOLEAN', defaultValue: false, allowNull: false }, + rebooking_enabled: { type: 'BOOLEAN', defaultValue: false, allowNull: false }, + competitor_insights_enabled: { type: 'BOOLEAN', defaultValue: false, allowNull: false }, + competitor_urls: { type: 'TEXT' }, + review_widget_theme: { type: 'TEXT', defaultValue: 'light', allowNull: false }, +}; + +function normalizeColumnDefinition(Sequelize, definition) { + const normalized = { ...definition }; + + if (definition.type === 'TEXT') { + normalized.type = Sequelize.DataTypes.TEXT; + } + + if (definition.type === 'BOOLEAN') { + normalized.type = Sequelize.DataTypes.BOOLEAN; + } + + if (definition.type === 'INTEGER') { + normalized.type = Sequelize.DataTypes.INTEGER; + } + + return normalized; +} + +async function addColumnsIfMissing(queryInterface, Sequelize, transaction, tableName, columns) { + const table = await queryInterface.describeTable(tableName); + + for (const [columnName, definition] of Object.entries(columns)) { + if (!table[columnName]) { + await queryInterface.addColumn( + tableName, + columnName, + normalizeColumnDefinition(Sequelize, definition), + { transaction }, + ); + } + } +} + +async function removeColumnsIfPresent(queryInterface, transaction, tableName, columns) { + const table = await queryInterface.describeTable(tableName); + + for (const columnName of Object.keys(columns).reverse()) { + if (table[columnName]) { + await queryInterface.removeColumn(tableName, columnName, { transaction }); + } + } +} + +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'businesses', businessColumns); + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + await removeColumnsIfPresent(queryInterface, transaction, 'businesses', businessColumns); + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; diff --git a/backend/src/db/models/businesses.js b/backend/src/db/models/businesses.js index e8751bb..2ca0e56 100644 --- a/backend/src/db/models/businesses.js +++ b/backend/src/db/models/businesses.js @@ -288,6 +288,97 @@ custom_review_link: { + }, + + + business_type: { + type: DataTypes.TEXT, + allowNull: false, + defaultValue: 'hybrid', + }, + + automation_mode: { + type: DataTypes.TEXT, + allowNull: false, + defaultValue: 'set_and_forget', + }, + + followup_enabled: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + + followup_delay_days: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 3, + }, + + max_followups: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 1, + }, + + ai_reply_enabled: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + + referral_enabled: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + + referral_offer: { + type: DataTypes.TEXT, + }, + + nps_enabled: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + + nps_question: { + type: DataTypes.TEXT, + }, + + social_widget_enabled: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + + broadcast_enabled: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + + rebooking_enabled: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + + competitor_insights_enabled: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + + competitor_urls: { + type: DataTypes.TEXT, + }, + + review_widget_theme: { + type: DataTypes.TEXT, + allowNull: false, + defaultValue: 'light', }, importHash: { diff --git a/backend/src/db/seeders/20260629143000-pro-demo-customer.js b/backend/src/db/seeders/20260629143000-pro-demo-customer.js new file mode 100644 index 0000000..8a78730 --- /dev/null +++ b/backend/src/db/seeders/20260629143000-pro-demo-customer.js @@ -0,0 +1,387 @@ +'use strict'; + +const bcrypt = require('bcrypt'); +const config = require('../../config'); + +const DEMO_PASSWORD = 'ProDemo2026!'; +const ids = { + user: '78e31e71-a4cd-4e73-aa11-2b0b681f0a33', + business: '1f862b7d-1b83-4a37-95ff-6b23a0f91d01', + customerA: 'b780ce14-f076-42a6-9d14-70c0559ef4e7', + customerB: '089a6496-37a5-47b6-b282-814d8d4bbb49', + customerC: 'cc7c37d2-f93a-4817-8a36-d2c26ee98b26', + transactionA: '0e57256e-868b-4d69-a896-45f9becc926b', + transactionB: '129b1899-4dc4-4cc8-8b30-6af4c8e64b8e', + requestA: '9fcad09d-4d77-49e6-a08c-e7e65f8938ac', + requestB: 'cb93d8a8-c6a7-44fc-af98-e0b18990561f', + requestC: '4d8347f1-694b-40ec-90a7-680c7fb8501f', + eventA: 'c9965d28-3895-4263-8fdf-ee05a5a709d3', + eventB: 'c251b361-d407-49f3-9694-6d0e72d0dd8a', + emailLogA: 'eb0c8667-9913-4851-b443-e18cbb22737e', +}; + +module.exports = { + up: async (queryInterface, Sequelize) => { + const now = new Date(); + const trialStartedAt = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000); + const trialEndsAt = new Date(now.getTime() + 12 * 24 * 60 * 60 * 1000); + const paidAtA = new Date(now.getTime() - 36 * 60 * 60 * 1000); + const paidAtB = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const scheduledSoon = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000); + const sentYesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); + const reviewedLastWeek = new Date(now.getTime() - 6 * 24 * 60 * 60 * 1000); + const passwordHash = bcrypt.hashSync(DEMO_PASSWORD, config.bcrypt.saltRounds); + const [accountOwnerRole] = await queryInterface.sequelize.query( + 'SELECT id FROM "roles" WHERE name = :roleName LIMIT 1', + { + replacements: { roleName: 'Account Owner' }, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + if (!accountOwnerRole?.id) { + throw new Error('Cannot seed Pro demo customer: Account Owner role was not found.'); + } + + const [existingDemoUser] = await queryInterface.sequelize.query( + 'SELECT id FROM "users" WHERE email = :email LIMIT 1', + { + replacements: { email: 'pro@reviewflow.demo' }, + type: Sequelize.QueryTypes.SELECT, + }, + ); + const demoUserId = existingDemoUser?.id || ids.user; + + if (existingDemoUser?.id) { + await queryInterface.sequelize.query( + `UPDATE "users" + SET "firstName" = :firstName, + "lastName" = :lastName, + "emailVerified" = true, + "provider" = :provider, + "password" = :password, + "disabled" = false, + "subscriptionPlanId" = :subscriptionPlanId, + "subscriptionStatus" = :subscriptionStatus, + "trialStartedAt" = :trialStartedAt, + "trialEndsAt" = :trialEndsAt, + "app_roleId" = :appRoleId, + "updatedAt" = :updatedAt, + "deletedAt" = NULL + WHERE id = :id`, + { + replacements: { + id: demoUserId, + firstName: 'Pro', + lastName: 'Demo Customer', + provider: config.providers.LOCAL, + password: passwordHash, + subscriptionPlanId: 'pro', + subscriptionStatus: 'trialing', + trialStartedAt, + trialEndsAt, + appRoleId: accountOwnerRole.id, + updatedAt: now, + }, + }, + ); + } else { + await queryInterface.bulkInsert('users', [ + { + id: demoUserId, + firstName: 'Pro', + lastName: 'Demo Customer', + email: 'pro@reviewflow.demo', + disabled: false, + password: passwordHash, + emailVerified: true, + provider: config.providers.LOCAL, + subscriptionPlanId: 'pro', + subscriptionStatus: 'trialing', + trialStartedAt, + trialEndsAt, + app_roleId: accountOwnerRole.id, + createdById: demoUserId, + updatedById: demoUserId, + createdAt: now, + updatedAt: now, + }, + ]); + } + + await queryInterface.sequelize.query( + `UPDATE "users" + SET "createdById" = COALESCE("createdById", :id), + "updatedById" = :id + WHERE id = :id`, + { replacements: { id: demoUserId } }, + ); + + await queryInterface.bulkDelete('email_delivery_logs', { id: ids.emailLogA }); + await queryInterface.bulkDelete('stripe_events', { id: { [Sequelize.Op.in]: [ids.eventA, ids.eventB] } }); + await queryInterface.bulkDelete('review_requests', { id: { [Sequelize.Op.in]: [ids.requestA, ids.requestB, ids.requestC] } }); + await queryInterface.bulkDelete('transactions', { id: { [Sequelize.Op.in]: [ids.transactionA, ids.transactionB] } }); + await queryInterface.bulkDelete('customers', { id: { [Sequelize.Op.in]: [ids.customerA, ids.customerB, ids.customerC] } }); + await queryInterface.bulkDelete('businesses', { id: ids.business }); + + await queryInterface.bulkInsert('businesses', [ + { + id: ids.business, + ownerId: demoUserId, + name: 'Harbor Freight Logistics', + business_type: 'hybrid', + review_destination: 'google', + google_review_link: 'https://g.page/r/harbor-freight-logistics/review', + yelp_review_link: 'https://www.yelp.com/biz/harbor-freight-logistics', + facebook_review_link: 'https://www.facebook.com/harborfreightlogistics/reviews', + trustpilot_review_link: 'https://www.trustpilot.com/review/harborfreightlogistics.example', + custom_review_link: 'https://reviews.example.com/harbor-freight-logistics', + delay_days: 3, + email_subject_template: 'How was your delivery with Harbor Freight Logistics?', + email_body_template: 'Hi {customerName}, thanks for choosing Harbor Freight Logistics. Please leave a review here: {reviewLink}', + is_active: true, + stripe_connected: true, + stripe_account_reference: 'acct_demo_harbor_logistics', + stripe_connected_at: now, + paypal_connected: true, + paypal_merchant_reference: 'merchant_demo_harbor_logistics', + paypal_connected_at: now, + shopify_connected: true, + shopify_store_reference: 'harbor-demo.myshopify.com', + shopify_connected_at: now, + shopify_hosted_reviews_enabled: true, + automation_mode: 'set_and_forget', + followup_enabled: true, + followup_delay_days: 3, + max_followups: 1, + ai_reply_enabled: true, + referral_enabled: true, + referral_offer: '$25 credit for every referred shipper who books a delivery.', + nps_enabled: true, + nps_question: 'How likely are you to recommend our delivery team?', + social_widget_enabled: true, + broadcast_enabled: true, + rebooking_enabled: true, + competitor_insights_enabled: true, + competitor_urls: 'https://www.trustpilot.com/review/example-competitor-logistics', + review_widget_theme: 'light', + createdById: demoUserId, + updatedById: demoUserId, + createdAt: now, + updatedAt: now, + }, + ]); + + await queryInterface.bulkInsert('customers', [ + { + id: ids.customerA, + businessId: ids.business, + email: 'mia@northstarfoods.example', + name: 'Mia Torres', + phone: '+1 555 0101', + stripe_customer_reference: 'cus_demo_mia_torres', + contact_status: 'active', + last_transaction_at: paidAtA, + createdById: demoUserId, + updatedById: demoUserId, + createdAt: paidAtA, + updatedAt: now, + }, + { + id: ids.customerB, + businessId: ids.business, + email: 'dispatch@evergreenretail.example', + name: 'Evergreen Retail Dispatch', + phone: '+1 555 0102', + paypal_customer_reference: 'payer_demo_evergreen', + contact_status: 'active', + last_transaction_at: paidAtB, + createdById: demoUserId, + updatedById: demoUserId, + createdAt: paidAtB, + updatedAt: now, + }, + { + id: ids.customerC, + businessId: ids.business, + email: 'ops@ridgewayparts.example', + name: 'Ridgeway Parts Ops', + phone: '+1 555 0103', + shopify_customer_reference: 'shopify_customer_demo_ridgeway', + contact_status: 'active', + last_transaction_at: reviewedLastWeek, + createdById: demoUserId, + updatedById: demoUserId, + createdAt: reviewedLastWeek, + updatedAt: now, + }, + ]); + + await queryInterface.bulkInsert('transactions', [ + { + id: ids.transactionA, + businessId: ids.business, + customerId: ids.customerA, + stripe_payment_reference: 'pi_demo_roadway_1001', + payment_provider: 'stripe', + provider_event_reference: 'evt_demo_roadway_1001', + amount: 1280.5, + currency: 'USD', + payment_status: 'succeeded', + paid_at: paidAtA, + description: 'Temperature-controlled regional delivery', + receipt_email: 'mia@northstarfoods.example', + createdById: demoUserId, + updatedById: demoUserId, + createdAt: paidAtA, + updatedAt: now, + }, + { + id: ids.transactionB, + businessId: ids.business, + customerId: ids.customerB, + paypal_payment_reference: 'paypal_demo_evergreen_2044', + payment_provider: 'paypal', + provider_event_reference: 'WH-DEMO-EVERGREEN-2044', + amount: 845, + currency: 'USD', + payment_status: 'succeeded', + paid_at: paidAtB, + description: 'Last-mile pallet delivery', + receipt_email: 'dispatch@evergreenretail.example', + createdById: demoUserId, + updatedById: demoUserId, + createdAt: paidAtB, + updatedAt: now, + }, + ]); + + await queryInterface.bulkInsert('review_requests', [ + { + id: ids.requestA, + businessId: ids.business, + customerId: ids.customerA, + transactionId: ids.transactionA, + status: 'pending', + scheduled_for: scheduledSoon, + email_subject: 'How was your delivery with Harbor Freight Logistics?', + email_body: 'Hi Mia,\n\nThank you for choosing Harbor Freight Logistics. Would you share a quick review of your delivery experience?\n\nLeave a review: https://g.page/r/harbor-freight-logistics/review\n\nThank you,\nHarbor Freight Logistics', + review_link: 'https://g.page/r/harbor-freight-logistics/review', + tracking_token: 'demo-pro-pending-token', + review_platform: 'google', + createdById: demoUserId, + updatedById: demoUserId, + createdAt: paidAtA, + updatedAt: now, + }, + { + id: ids.requestB, + businessId: ids.business, + customerId: ids.customerB, + transactionId: ids.transactionB, + status: 'clicked', + scheduled_for: sentYesterday, + sent_at: sentYesterday, + clicked_at: new Date(sentYesterday.getTime() + 3 * 60 * 60 * 1000), + email_subject: 'Thanks from Harbor Freight Logistics', + email_body: 'Hi Evergreen Retail Dispatch,\n\nThanks for trusting our team with your pallet delivery. Please share your feedback when you have a moment.\n\nLeave a review: https://reviews.example.com/harbor-freight-logistics\n\nThank you,\nHarbor Freight Logistics', + review_link: 'https://reviews.example.com/harbor-freight-logistics', + tracking_token: 'demo-pro-clicked-token', + review_platform: 'custom', + createdById: demoUserId, + updatedById: demoUserId, + createdAt: sentYesterday, + updatedAt: now, + }, + { + id: ids.requestC, + businessId: ids.business, + customerId: ids.customerC, + status: 'reviewed', + scheduled_for: reviewedLastWeek, + sent_at: reviewedLastWeek, + clicked_at: new Date(reviewedLastWeek.getTime() + 2 * 60 * 60 * 1000), + reviewed_at: new Date(reviewedLastWeek.getTime() + 4 * 60 * 60 * 1000), + submitted_at: new Date(reviewedLastWeek.getTime() + 4 * 60 * 60 * 1000), + email_subject: 'How did our delivery team do?', + email_body: 'Hi Ridgeway Parts Ops,\n\nThanks for your order. Please rate your delivery experience using the hosted review form.\n\nLeave a review: /review/demo-pro-reviewed-token\n\nThank you,\nHarbor Freight Logistics', + review_link: '/review/demo-pro-reviewed-token', + tracking_token: 'demo-pro-reviewed-token', + review_platform: 'shopify_hosted', + review_rating: 5, + review_title: 'Dependable delivery team', + review_content: 'The driver arrived on schedule and the shipment tracking updates were clear.', + reviewer_display_name: 'Ridgeway Parts Ops', + createdById: demoUserId, + updatedById: demoUserId, + createdAt: reviewedLastWeek, + updatedAt: now, + }, + ]); + + await queryInterface.bulkInsert('stripe_events', [ + { + id: ids.eventA, + businessId: ids.business, + stripe_event_reference: 'evt_demo_roadway_1001', + provider: 'stripe', + provider_event_type: 'payment_intent.succeeded', + event_type: 'payment_intent_succeeded', + received_at: paidAtA, + processed: true, + processed_at: paidAtA, + payload_json: JSON.stringify({ demo: true, provider: 'stripe', amount: 1280.5 }), + createdById: demoUserId, + updatedById: demoUserId, + createdAt: paidAtA, + updatedAt: now, + }, + { + id: ids.eventB, + businessId: ids.business, + stripe_event_reference: 'WH-DEMO-EVERGREEN-2044', + provider: 'paypal', + provider_event_type: 'PAYMENT.CAPTURE.COMPLETED', + event_type: 'charge_succeeded', + received_at: paidAtB, + processed: true, + processed_at: paidAtB, + payload_json: JSON.stringify({ demo: true, provider: 'paypal', amount: 845 }), + createdById: demoUserId, + updatedById: demoUserId, + createdAt: paidAtB, + updatedAt: now, + }, + ]); + + await queryInterface.bulkInsert('email_delivery_logs', [ + { + id: ids.emailLogA, + review_requestId: ids.requestB, + provider: 'smtp', + provider_message_reference: 'smtp-demo-clicked-request', + delivery_status: 'delivered', + queued_at: sentYesterday, + sent_at: sentYesterday, + delivered_at: new Date(sentYesterday.getTime() + 8 * 60 * 1000), + to_email: 'dispatch@evergreenretail.example', + from_email: 'ReviewFlow ', + subject: 'Thanks from Harbor Freight Logistics', + createdById: demoUserId, + updatedById: demoUserId, + createdAt: sentYesterday, + updatedAt: now, + }, + ]); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.bulkDelete('email_delivery_logs', { id: ids.emailLogA }); + await queryInterface.bulkDelete('stripe_events', { id: { [Sequelize.Op.in]: [ids.eventA, ids.eventB] } }); + await queryInterface.bulkDelete('review_requests', { id: { [Sequelize.Op.in]: [ids.requestA, ids.requestB, ids.requestC] } }); + await queryInterface.bulkDelete('transactions', { id: { [Sequelize.Op.in]: [ids.transactionA, ids.transactionB] } }); + await queryInterface.bulkDelete('customers', { id: { [Sequelize.Op.in]: [ids.customerA, ids.customerB, ids.customerC] } }); + await queryInterface.bulkDelete('businesses', { id: ids.business }); + await queryInterface.bulkDelete('users', { email: 'pro@reviewflow.demo' }); + }, +}; diff --git a/backend/src/routes/reviewflow-public.js b/backend/src/routes/reviewflow-public.js index 750c83a..baf6b1d 100644 --- a/backend/src/routes/reviewflow-public.js +++ b/backend/src/routes/reviewflow-public.js @@ -19,6 +19,72 @@ router.post('/reviews/:trackingToken', wrapAsync(async (req, res) => { res.status(200).send({ review }); })); + +function escapeHtml(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +router.get('/widgets/:businessId', wrapAsync(async (req, res) => { + const widget = await ReviewFlowService.getSocialWidgetReviews(req.params.businessId, req.query || {}); + + if (req.query.format === 'json') { + res.status(200).send(widget); + return; + } + + const reviewsHtml = widget.reviews.length + ? widget.reviews.map((review) => ` +
+
${'★'.repeat(Number(review.rating) || 5)}
+

${escapeHtml(review.title || 'Great experience')}

+

${escapeHtml(review.content)}

+
${escapeHtml(review.reviewer)} · ${escapeHtml(review.source)}
+
+ `).join('') + : '
Fresh reviews will appear here after customers submit them.
'; + + res.status(200).type('html').send(` + + + + + + + +
+
+
+
Verified reviews
+

${escapeHtml(widget.business.name)}

+
+
Powered by Review Flow
+
+
${reviewsHtml}
+
+ +`); +})); + router.use('/', require('../helpers').commonErrorHandler); module.exports = router; diff --git a/backend/src/routes/reviewflow.js b/backend/src/routes/reviewflow.js index 63045f2..f0c5515 100644 --- a/backend/src/routes/reviewflow.js +++ b/backend/src/routes/reviewflow.js @@ -54,6 +54,34 @@ function normalizeReviewDestination(value) { return 'google'; } +function normalizeBusinessType(value, fallback = 'hybrid') { + return ReviewFlowService.normalizeBusinessType(value, fallback); +} + +function parseBoolean(value, fallback = false) { + if (value === undefined || value === null || value === '') { + return fallback; + } + + return value === true || value === 'true' || value === 'on' || value === 1 || value === '1'; +} + +function parseInteger(value, fallback, min, max) { + const parsed = Number(value); + + if (!Number.isFinite(parsed)) { + return fallback; + } + + return Math.max(min, Math.min(Math.round(parsed), max)); +} + +async function assertProFeatureForEnabledFlag(currentUser, enabled, featureKey) { + if (enabled) { + await SubscriptionService.assertFeatureAccess(currentUser, featureKey); + } +} + function getReviewLinkField(reviewDestination) { return REVIEW_LINK_FIELDS[reviewDestination] || null; } @@ -126,6 +154,7 @@ router.get('/summary', wrapAsync(async (req, res) => { paymentEvents, recentTransactions, recentEvents, + businesses, ] = await Promise.all([ db.review_requests.count({ where: { createdById: currentUser.id, status: 'pending' } }), db.review_requests.count({ where: { createdById: currentUser.id, status: 'sent' } }), @@ -151,6 +180,11 @@ router.get('/summary', wrapAsync(async (req, res) => { order: [['createdAt', 'DESC']], limit: 6, }), + db.businesses.findAll({ + where: { createdById: currentUser.id }, + order: [['updatedAt', 'DESC']], + limit: 25, + }), ]); res.status(200).send({ @@ -158,6 +192,8 @@ router.get('/summary', wrapAsync(async (req, res) => { requests, recentTransactions, recentEvents, + businesses: businesses.map((business) => ReviewFlowService.serializeBusiness(req, business)), + primaryBusiness: businesses[0] ? ReviewFlowService.serializeBusiness(req, businesses[0]) : null, }); })); @@ -167,6 +203,12 @@ router.post('/request', wrapAsync(async (req, res) => { const businessName = normalizeString(body.businessName); const reviewLink = normalizeString(body.reviewLink); const reviewDestination = normalizeReviewDestination(body.reviewDestination || body.reviewPlatform || 'google'); + const businessType = normalizeBusinessType(body.businessType || body.business_type, 'hybrid'); + if (!ReviewFlowService.isReviewDestinationAllowedForBusinessType(businessType, reviewDestination)) { + const error = new Error('This review destination does not match the selected business type. Choose Hybrid if this business needs both local and online options.'); + error.code = 400; + throw error; + } const isHostedReviewDestination = reviewDestination === 'shopify_hosted'; const reviewLinkField = getReviewLinkField(reviewDestination); const customerEmail = normalizeString(body.customerEmail).toLowerCase(); @@ -206,10 +248,12 @@ router.post('/request', wrapAsync(async (req, res) => { ? ReviewFlowService.getHostedReviewUrl(req, trackingToken) : reviewLink; const transaction = await db.sequelize.transaction(); + let transactionCommitted = false; try { const businessDefaults = { name: businessName, + business_type: businessType, review_destination: reviewDestination, shopify_hosted_reviews_enabled: isHostedReviewDestination, delay_days: delayDays, @@ -218,6 +262,7 @@ router.post('/request', wrapAsync(async (req, res) => { is_active: true, createdById: currentUser.id, updatedById: currentUser.id, + ownerId: currentUser.id, }; if (reviewLink && reviewLinkField) { @@ -231,6 +276,7 @@ router.post('/request', wrapAsync(async (req, res) => { }); const businessUpdates = { + business_type: businessType, review_destination: reviewDestination, shopify_hosted_reviews_enabled: business.shopify_hosted_reviews_enabled || isHostedReviewDestination, delay_days: delayDays, @@ -282,6 +328,16 @@ router.post('/request', wrapAsync(async (req, res) => { }, { transaction }); await transaction.commit(); + transactionCommitted = true; + + let delivery = null; + + if (scheduledFor.getTime() <= Date.now()) { + delivery = await ReviewFlowService.processDueReviewRequests(currentUser, { + limit: 1, + requestId: reviewRequest.id, + }); + } const createdRequest = await db.review_requests.findByPk(reviewRequest.id, { include: [ @@ -290,13 +346,137 @@ router.post('/request', wrapAsync(async (req, res) => { ], }); - res.status(201).send({ request: createdRequest }); + res.status(201).send({ request: createdRequest, delivery }); } catch (error) { - await transaction.rollback(); + if (!transactionCommitted) { + await transaction.rollback(); + } throw error; } })); + +router.post('/automation/run-due', wrapAsync(async (req, res) => { + await SubscriptionService.assertFeatureAccess(req.currentUser, 'set_and_forget_automation'); + const result = await ReviewFlowService.processDueReviewRequests(req.currentUser, req.body || {}); + + res.status(200).send(result); +})); + +router.put('/growth-tools/business', wrapAsync(async (req, res) => { + const currentUser = req.currentUser; + const body = req.body || {}; + const businessId = normalizeString(body.businessId || body.id); + const businessName = normalizeString(body.businessName || body.name || 'Review Flow Business'); + let business = businessId + ? await db.businesses.findOne({ where: { id: businessId, createdById: currentUser.id } }) + : await db.businesses.findOne({ where: { name: businessName, createdById: currentUser.id } }); + + if (businessId && !business) { + const error = new Error('Business not found for this account.'); + error.code = 404; + throw error; + } + + if (!business) { + await SubscriptionService.assertCanCreateBusinesses(currentUser, 1); + business = await db.businesses.create({ + name: businessName, + business_type: normalizeBusinessType(body.businessType || body.business_type, 'hybrid'), + automation_mode: 'set_and_forget', + is_active: true, + createdById: currentUser.id, + updatedById: currentUser.id, + ownerId: currentUser.id, + }); + } + + const businessType = normalizeBusinessType(body.businessType || body.business_type, business.business_type || 'hybrid'); + const reviewDestination = normalizeReviewDestination(body.reviewDestination || body.review_destination || business.review_destination || 'google'); + + if (!ReviewFlowService.isReviewDestinationAllowedForBusinessType(businessType, reviewDestination)) { + const error = new Error('This review destination does not match the selected business type. Choose Hybrid if this business needs both local and online options.'); + error.code = 400; + throw error; + } + + const aiReplyEnabled = parseBoolean(body.aiReplyEnabled ?? body.ai_reply_enabled, business.ai_reply_enabled); + const referralEnabled = parseBoolean(body.referralEnabled ?? body.referral_enabled, business.referral_enabled); + const npsEnabled = parseBoolean(body.npsEnabled ?? body.nps_enabled, business.nps_enabled); + const broadcastEnabled = parseBoolean(body.broadcastEnabled ?? body.broadcast_enabled, business.broadcast_enabled); + const rebookingEnabled = parseBoolean(body.rebookingEnabled ?? body.rebooking_enabled, business.rebooking_enabled); + const competitorInsightsEnabled = parseBoolean(body.competitorInsightsEnabled ?? body.competitor_insights_enabled, business.competitor_insights_enabled); + + await assertProFeatureForEnabledFlag(currentUser, aiReplyEnabled, 'ai_review_replies'); + await assertProFeatureForEnabledFlag(currentUser, referralEnabled, 'referral_campaigns'); + await assertProFeatureForEnabledFlag(currentUser, npsEnabled, 'nps_surveys'); + await assertProFeatureForEnabledFlag(currentUser, broadcastEnabled, 'marketing_broadcasts'); + await assertProFeatureForEnabledFlag(currentUser, rebookingEnabled, 'rebooking_campaigns'); + await assertProFeatureForEnabledFlag(currentUser, competitorInsightsEnabled, 'competitor_insights'); + + const updatePayload = { + name: businessName || business.name, + business_type: businessType, + automation_mode: normalizeString(body.automationMode || body.automation_mode) || 'set_and_forget', + review_destination: reviewDestination, + delay_days: parseInteger(body.delayDays ?? body.delay_days, business.delay_days || 7, 0, 30), + followup_enabled: parseBoolean(body.followupEnabled ?? body.followup_enabled, business.followup_enabled !== false), + followup_delay_days: parseInteger(body.followupDelayDays ?? body.followup_delay_days, business.followup_delay_days || 3, 1, 30), + max_followups: parseInteger(body.maxFollowups ?? body.max_followups, business.max_followups || 1, 0, 5), + ai_reply_enabled: aiReplyEnabled, + referral_enabled: referralEnabled, + referral_offer: normalizeString(body.referralOffer ?? body.referral_offer) || business.referral_offer || '', + nps_enabled: npsEnabled, + nps_question: normalizeString(body.npsQuestion ?? body.nps_question) || business.nps_question || 'How likely are you to recommend us to a friend?', + social_widget_enabled: parseBoolean(body.socialWidgetEnabled ?? body.social_widget_enabled, business.social_widget_enabled !== false), + broadcast_enabled: broadcastEnabled, + rebooking_enabled: rebookingEnabled, + competitor_insights_enabled: competitorInsightsEnabled, + competitor_urls: normalizeString(body.competitorUrls ?? body.competitor_urls) || business.competitor_urls || '', + review_widget_theme: normalizeString(body.reviewWidgetTheme ?? body.review_widget_theme) || business.review_widget_theme || 'light', + is_active: true, + updatedById: currentUser.id, + }; + + await business.update(updatePayload); + const refreshedBusiness = await db.businesses.findByPk(business.id); + + res.status(200).send({ business: ReviewFlowService.serializeBusiness(req, refreshedBusiness) }); +})); + +router.post('/growth-tools/broadcast', wrapAsync(async (req, res) => { + const campaignType = normalizeString(req.body?.campaignType || 'broadcast'); + const featureByCampaign = { + broadcast: 'marketing_broadcasts', + referral: 'referral_campaigns', + nps: 'nps_surveys', + rebooking: 'rebooking_campaigns', + }; + + await SubscriptionService.assertFeatureAccess(req.currentUser, featureByCampaign[campaignType] || 'marketing_broadcasts'); + const result = await ReviewFlowService.queueCustomerCampaign(req.currentUser, req.body || {}); + + res.status(200).send(result); +})); + +router.post('/growth-tools/competitor-insights', wrapAsync(async (req, res) => { + await SubscriptionService.assertFeatureAccess(req.currentUser, 'competitor_insights'); + const result = await ReviewFlowService.buildCompetitorInsights(req.currentUser, req.body || {}); + + res.status(200).send(result); +})); + +router.get('/social-widget/:businessId', wrapAsync(async (req, res) => { + await SubscriptionService.assertFeatureAccess(req.currentUser, 'social_proof_widgets'); + const result = await ReviewFlowService.getSocialWidgetReviews(req.params.businessId, req.query || {}); + const origin = `${req.protocol}://${req.get('host')}`; + + res.status(200).send({ + ...result, + embedCode: ``, + }); +})); + router.use('/', require('../helpers').commonErrorHandler); module.exports = router; diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index 19df9ae..32c9abe 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -347,7 +347,6 @@ router.get('/count', wrapAsync(async (req, res) => { const currentUser = req.currentUser; const payload = await UsersDBApi.findAll( req.query, - null, { countOnly: true, currentUser } ); @@ -385,7 +384,7 @@ router.get('/autocomplete', async (req, res) => { req.query.query, req.query.limit, req.query.offset, - + { currentUser: req.currentUser }, ); res.status(200).send(payload); @@ -426,11 +425,16 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await UsersDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); - - - delete payload.password; - + + if (!payload) { + const error = new Error('User not found.'); + error.code = 404; + throw error; + } + + delete payload.password; res.status(200).send(payload); })); diff --git a/backend/src/services/reviewflow.js b/backend/src/services/reviewflow.js index 7877714..b982b5e 100644 --- a/backend/src/services/reviewflow.js +++ b/backend/src/services/reviewflow.js @@ -1,5 +1,8 @@ const crypto = require('crypto'); +const axios = require('axios'); +const config = require('../config'); const db = require('../db/models'); +const EmailSender = require('./email'); const SubscriptionService = require('./subscription'); const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -156,6 +159,12 @@ const REVIEW_CHANNELS = { }, }; +const BUSINESS_TYPES = new Set(['local', 'online', 'hybrid']); +const LOCAL_REVIEW_DESTINATIONS = new Set(['google', 'facebook', 'yelp', 'angi', 'opentable', 'custom']); +const ONLINE_REVIEW_DESTINATIONS = new Set(['trustpilot', 'shopify_hosted', 'custom']); +const LOCAL_TRIGGER_PROVIDERS = new Set(['stripe', 'square', 'paypal']); +const ONLINE_TRIGGER_PROVIDERS = new Set(['stripe', 'paypal', 'shopify', 'woocommerce']); + function normalizeString(value) { return typeof value === 'string' ? value.trim() : ''; } @@ -182,6 +191,60 @@ function getNormalizedReviewDestination(value) { return 'google'; } +function normalizeBusinessType(value, fallback = 'hybrid') { + const normalizedType = normalizeString(value || fallback).toLowerCase(); + + if (BUSINESS_TYPES.has(normalizedType)) { + return normalizedType; + } + + return BUSINESS_TYPES.has(fallback) ? fallback : 'hybrid'; +} + +function isReviewDestinationAllowedForBusinessType(businessType, destination) { + const normalizedType = normalizeBusinessType(businessType); + const normalizedDestination = getNormalizedReviewDestination(destination); + + if (normalizedType === 'hybrid') { + return true; + } + + if (normalizedType === 'local') { + return LOCAL_REVIEW_DESTINATIONS.has(normalizedDestination); + } + + return ONLINE_REVIEW_DESTINATIONS.has(normalizedDestination); +} + +function assertReviewDestinationAllowedForBusinessType(businessType, destination) { + if (!isReviewDestinationAllowedForBusinessType(businessType, destination)) { + const label = normalizeBusinessType(businessType) === 'local' ? 'local' : 'online/ecommerce'; + throw httpError(`This review destination does not match a ${label} business setup. Choose Hybrid if you need both local and online options.`, 400); + } +} + +function isProviderAllowedForBusinessType(businessType, provider) { + const normalizedType = normalizeBusinessType(businessType); + const normalizedProvider = normalizeString(provider).toLowerCase(); + + if (normalizedType === 'hybrid') { + return Boolean(PROVIDERS[normalizedProvider]); + } + + if (normalizedType === 'local') { + return LOCAL_TRIGGER_PROVIDERS.has(normalizedProvider); + } + + return ONLINE_TRIGGER_PROVIDERS.has(normalizedProvider); +} + +function assertProviderAllowedForBusinessType(businessType, provider) { + if (!isProviderAllowedForBusinessType(businessType, provider)) { + const label = normalizeBusinessType(businessType) === 'local' ? 'local' : 'online/ecommerce'; + throw httpError(`This provider does not match a ${label} business setup. Choose Hybrid if this business needs both local and online triggers.`, 400); + } +} + function getReviewChannel(destination) { return REVIEW_CHANNELS[getNormalizedReviewDestination(destination)]; } @@ -337,6 +400,191 @@ function buildEmailBody(customerName, businessName, reviewLink) { ].join('\n'); } + +function escapeHtml(value) { + return normalizeString(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function textToHtml(value) { + return escapeHtml(value) + .split('\n') + .map((line) => line || ' ') + .join('
'); +} + +function buildReviewRequestEmail(request, toEmail) { + const businessName = request.business?.name || 'Review Flow'; + const customerName = request.customer?.name || 'there'; + const reviewLink = request.review_link || ''; + const body = request.email_body || buildEmailBody(customerName, businessName, reviewLink); + + return { + to: toEmail, + subject: request.email_subject || `How was your experience with ${businessName}?`, + html: async () => ` +
+
+
${textToHtml(body)}
+ ${reviewLink ? `

Leave a review

` : ''} +
+

Sent by Review Flow for ${escapeHtml(businessName)}.

+
+ `, + }; +} + +function getSmsConfig() { + return { + accountSid: process.env.TWILIO_ACCOUNT_SID || '', + authToken: process.env.TWILIO_AUTH_TOKEN || '', + fromNumber: process.env.TWILIO_FROM_NUMBER || process.env.SMS_FROM_NUMBER || '', + }; +} + +function isSmsConfigured() { + const smsConfig = getSmsConfig(); + return Boolean(smsConfig.accountSid && smsConfig.authToken && smsConfig.fromNumber); +} + +async function sendReviewRequestSms(request) { + const toPhone = normalizeString(request.customer?.phone); + + if (!toPhone) { + return { channel: 'sms', status: 'skipped', reason: 'No customer phone number was provided.' }; + } + + if (!isSmsConfigured()) { + return { + channel: 'sms', + status: 'skipped', + to: toPhone, + reason: 'SMS provider is not configured. Set TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_FROM_NUMBER to enable SMS delivery.', + }; + } + + const smsConfig = getSmsConfig(); + const businessName = request.business?.name || 'our business'; + const reviewLink = request.review_link || ''; + const message = `Thanks for choosing ${businessName}. Please leave a review: ${reviewLink}`.slice(0, 1500); + const payload = new URLSearchParams({ + To: toPhone, + From: smsConfig.fromNumber, + Body: message, + }); + + try { + const response = await axios.post( + `https://api.twilio.com/2010-04-01/Accounts/${encodeURIComponent(smsConfig.accountSid)}/Messages.json`, + payload, + { + auth: { + username: smsConfig.accountSid, + password: smsConfig.authToken, + }, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + timeout: 15000, + }, + ); + + return { + channel: 'sms', + status: 'sent', + to: toPhone, + providerMessageReference: response.data?.sid || null, + }; + } catch (error) { + console.error('Review Flow SMS delivery failed:', { + requestId: request.id, + to: toPhone, + message: error.message, + response: error.response?.data, + }); + + return { + channel: 'sms', + status: 'failed', + to: toPhone, + reason: error.response?.data?.message || error.message, + }; + } +} + +async function sendReviewRequestNotifications(request, currentUser, options = {}) { + const now = options.now || new Date(); + const toEmail = normalizeEmail(request.customer?.email); + const deliveries = []; + + if (!EMAIL_PATTERN.test(toEmail)) { + const error = new Error(`Missing customer email for request ${request.id}`); + error.code = 'missing_customer_email'; + throw error; + } + + const emailLog = await db.email_delivery_logs.create({ + provider: EmailSender.isConfigured ? 'smtp' : 'unknown', + provider_message_reference: `reviewflow-${request.id}`, + delivery_status: 'queued', + queued_at: now, + to_email: toEmail, + from_email: config.email?.from || 'ReviewFlow ', + subject: request.email_subject, + review_requestId: request.id, + createdById: currentUser.id, + updatedById: currentUser.id, + }); + + if (!EmailSender.isConfigured) { + const reason = 'Email provider is not configured. Set EMAIL_USER and EMAIL_PASS to enable SMTP delivery.'; + await emailLog.update({ + delivery_status: 'failed', + error_details: reason, + updatedById: currentUser.id, + }); + const error = new Error(reason); + error.code = 'email_provider_not_configured'; + throw error; + } + + try { + const emailResult = await new EmailSender(buildReviewRequestEmail(request, toEmail)).send(); + await emailLog.update({ + provider: 'smtp', + provider_message_reference: emailResult?.messageId || emailLog.provider_message_reference, + delivery_status: 'sent', + sent_at: new Date(), + updatedById: currentUser.id, + }); + deliveries.push({ + channel: 'email', + status: 'sent', + to: toEmail, + providerMessageReference: emailResult?.messageId || null, + }); + } catch (error) { + console.error('Review Flow email delivery failed:', { + requestId: request.id, + to: toEmail, + message: error.message, + }); + await emailLog.update({ + delivery_status: 'failed', + error_details: error.message, + updatedById: currentUser.id, + }); + throw error; + } + + const smsDelivery = await sendReviewRequestSms(request); + deliveries.push(smsDelivery); + + return deliveries; +} + function renderTemplate(template, replacements) { if (!template) { return ''; @@ -672,6 +920,22 @@ function serializeBusiness(req, business) { return { id: business.id, name: business.name, + business_type: normalizeBusinessType(business.business_type), + automation_mode: business.automation_mode || 'set_and_forget', + followup_enabled: business.followup_enabled !== false, + followup_delay_days: business.followup_delay_days ?? 3, + max_followups: business.max_followups ?? 1, + ai_reply_enabled: Boolean(business.ai_reply_enabled), + referral_enabled: Boolean(business.referral_enabled), + referral_offer: business.referral_offer || '', + nps_enabled: Boolean(business.nps_enabled), + nps_question: business.nps_question || '', + social_widget_enabled: business.social_widget_enabled !== false, + broadcast_enabled: Boolean(business.broadcast_enabled), + rebooking_enabled: Boolean(business.rebooking_enabled), + competitor_insights_enabled: Boolean(business.competitor_insights_enabled), + competitor_urls: business.competitor_urls || '', + review_widget_theme: business.review_widget_theme || 'light', google_review_link: business.google_review_link, yelp_review_link: business.yelp_review_link, facebook_review_link: business.facebook_review_link, @@ -738,6 +1002,10 @@ async function connectProvider(currentUser, body, req) { business = await db.businesses.findOne({ where: { name: businessName, createdById: currentUser.id } }); } + const businessType = normalizeBusinessType(body.businessType || body.business_type, business?.business_type || 'hybrid'); + assertProviderAllowedForBusinessType(businessType, config.provider); + assertReviewDestinationAllowedForBusinessType(businessType, reviewDestination); + if (reviewLink) { validateUrl(reviewLink, 'Enter a valid review page URL before connecting a webhook.'); } @@ -751,6 +1019,7 @@ async function connectProvider(currentUser, body, req) { const createPayload = { name: businessName, + business_type: businessType, review_destination: reviewDestination, shopify_hosted_reviews_enabled: Boolean(config.hostedReviewProvider || reviewDestination === 'shopify_hosted'), delay_days: delayDays, @@ -771,6 +1040,7 @@ async function connectProvider(currentUser, body, req) { const updates = { is_active: true, + business_type: businessType, delay_days: delayDays, review_destination: reviewDestination, shopify_hosted_reviews_enabled: Boolean( @@ -797,6 +1067,238 @@ async function connectProvider(currentUser, body, req) { return serializeBusiness(req, refreshedBusiness); } +async function getSocialWidgetReviews(businessId, options = {}) { + const business = await db.businesses.findByPk(businessId); + + if (!business || business.social_widget_enabled === false) { + throw httpError('Review widget is not enabled for this business.', 404); + } + + const reviews = await db.review_requests.findAll({ + where: { + businessId: business.id, + status: 'reviewed', + review_rating: { [db.Sequelize.Op.gte]: Number(options.minimumRating) || 4 }, + }, + include: [ + { model: db.customers, as: 'customer' }, + { model: db.transactions, as: 'transaction' }, + ], + order: [['reviewed_at', 'DESC'], ['updatedAt', 'DESC']], + limit: Math.min(Number(options.limit) || 8, 25), + }); + + return { + business: { + id: business.id, + name: business.name, + business_type: normalizeBusinessType(business.business_type), + review_widget_theme: business.review_widget_theme || 'light', + }, + reviews: reviews.map((review) => ({ + id: review.id, + rating: review.review_rating, + title: review.review_title || '', + content: review.review_content || '', + reviewer: review.reviewer_display_name || review.customer?.name || 'Verified customer', + reviewed_at: review.reviewed_at || review.submitted_at || review.updatedAt, + source: review.review_platform || 'Review Flow', + transaction: review.transaction ? { + payment_provider: review.transaction.payment_provider, + description: review.transaction.description, + } : null, + })), + }; +} + +async function processDueReviewRequests(currentUser, options = {}) { + const now = new Date(); + const limit = Math.min(Number(options.limit) || 50, 200); + const where = { + createdById: currentUser.id, + status: 'pending', + scheduled_for: { [db.Sequelize.Op.lte]: now }, + }; + + if (options.requestId) { + where.id = options.requestId; + } + + const dueRequests = await db.review_requests.findAll({ + where, + include: [ + { model: db.businesses, as: 'business' }, + { model: db.customers, as: 'customer' }, + ], + order: [['scheduled_for', 'ASC']], + limit, + }); + const cronRun = await db.cron_runs.create({ + job_name: 'reviewflow_set_and_forget_due_requests', + started_at: now, + run_status: 'skipped', + processed_count: 0, + sent_count: 0, + error_count: 0, + createdById: currentUser.id, + updatedById: currentUser.id, + }); + + let sentCount = 0; + let errorCount = 0; + const errors = []; + const deliveries = []; + + for (const request of dueRequests) { + try { + const requestDeliveries = await sendReviewRequestNotifications(request, currentUser, { now }); + deliveries.push({ requestId: request.id, deliveries: requestDeliveries }); + + await request.update({ + status: 'sent', + sent_at: new Date(), + failure_reason: null, + updatedById: currentUser.id, + }); + sentCount += 1; + } catch (error) { + console.error('Review Flow request delivery failed:', { + requestId: request.id, + message: error.message, + }); + errorCount += 1; + errors.push(error.message); + deliveries.push({ + requestId: request.id, + deliveries: [{ channel: 'email', status: 'failed', reason: error.message }], + }); + await request.update({ + status: 'failed', + failure_reason: error.message, + updatedById: currentUser.id, + }); + } + } + + await cronRun.update({ + finished_at: new Date(), + run_status: errorCount && sentCount ? 'partial' : errorCount ? 'failed' : dueRequests.length ? 'success' : 'skipped', + processed_count: dueRequests.length, + sent_count: sentCount, + error_count: errorCount, + error_summary: errors.slice(0, 10).join('\n') || null, + updatedById: currentUser.id, + }); + + return { + processed: dueRequests.length, + sent: sentCount, + failed: errorCount, + errors, + deliveries, + cronRunId: cronRun.id, + }; +} + +async function queueCustomerCampaign(currentUser, body = {}) { + const businessId = normalizeString(body.businessId); + const subject = normalizeString(body.subject).slice(0, 200); + const message = normalizeString(body.message).slice(0, 5000); + const campaignType = normalizeString(body.campaignType || 'broadcast') || 'broadcast'; + + requireField(subject, 'Campaign subject is required.'); + requireField(message, 'Campaign message is required.'); + + const where = { createdById: currentUser.id }; + + if (businessId) { + where.businessId = businessId; + } + + const customers = await db.customers.findAll({ + where, + order: [['updatedAt', 'DESC']], + limit: Math.min(Number(body.limit) || 250, 500), + }); + const deliverableCustomers = customers.filter((customer) => EMAIL_PATTERN.test(normalizeEmail(customer.email))); + const now = new Date(); + + if (!deliverableCustomers.length) { + return { + queued: 0, + skipped: customers.length, + campaignType, + message: 'No customers with valid email addresses were found for this campaign.', + }; + } + + await db.email_delivery_logs.bulkCreate(deliverableCustomers.map((customer) => ({ + provider: 'unknown', + provider_message_reference: `${campaignType}-${customer.id}-${now.getTime()}`, + delivery_status: 'queued', + queued_at: now, + to_email: normalizeEmail(customer.email), + from_email: 'reviews@reviewflow.local', + subject, + error_details: `Queued ${campaignType} campaign from Growth Tools. Message: ${message}`, + createdById: currentUser.id, + updatedById: currentUser.id, + }))); + + return { + queued: deliverableCustomers.length, + skipped: customers.length - deliverableCustomers.length, + campaignType, + message: `${deliverableCustomers.length} ${campaignType} messages were queued for provider handoff.`, + }; +} + +async function buildCompetitorInsights(currentUser, body = {}) { + const businessId = normalizeString(body.businessId); + const competitorUrls = normalizeString(body.competitorUrls || body.competitor_urls); + + requireField(competitorUrls, 'Add at least one competitor URL or name.'); + + const business = businessId + ? await db.businesses.findOne({ where: { id: businessId, createdById: currentUser.id } }) + : await db.businesses.findOne({ where: { createdById: currentUser.id }, order: [['updatedAt', 'DESC']] }); + + if (!business) { + throw httpError('Create a business profile before saving competitor insights.', 400); + } + + await business.update({ + competitor_urls: competitorUrls, + competitor_insights_enabled: true, + updatedById: currentUser.id, + }); + + const [reviewed, pending, sent, customers] = await Promise.all([ + db.review_requests.count({ where: { createdById: currentUser.id, status: 'reviewed' } }), + db.review_requests.count({ where: { createdById: currentUser.id, status: 'pending' } }), + db.review_requests.count({ where: { createdById: currentUser.id, status: 'sent' } }), + db.customers.count({ where: { createdById: currentUser.id } }), + ]); + const competitors = competitorUrls.split('\n').map((item) => item.trim()).filter(Boolean).slice(0, 8); + + return { + business: serializeBusiness({ get: () => '', protocol: 'https', headers: {} }, business), + competitors, + metrics: { reviewed, pending, sent, customers }, + recommendations: [ + reviewed < 10 + ? 'Prioritize review volume first: keep automation on until you have at least 10 fresh testimonials for widgets and sales pages.' + : 'You have enough reviewed feedback to rotate testimonials into your social proof widget.', + pending > sent + ? 'There are more pending than sent requests. Run the set-it-and-forget-it processor or check email provider setup.' + : 'Your queue is moving. Keep request timing consistent so competitors cannot outpace your freshness.', + competitors.length > 3 + ? 'Track the top 3 competitors most often mentioned by prospects so the dashboard stays uncluttered.' + : 'Add 2–3 direct competitors and review their public messaging monthly.', + ], + }; +} + async function rotateWebhookToken(currentUser, businessId, provider, req) { const config = getProviderConfig(provider); const business = await db.businesses.findOne({ where: { id: businessId, createdById: currentUser.id } }); @@ -1198,12 +1700,19 @@ module.exports = { buildEmailBody, connectProvider, generateWebhookToken, + buildCompetitorInsights, getHostedReviewRequest, getHostedReviewUrl, getProviderConfig, getReviewDestination, getReviewLink, getWebhookUrl, + getSocialWidgetReviews, + isProviderAllowedForBusinessType, + isReviewDestinationAllowedForBusinessType, + normalizeBusinessType, + processDueReviewRequests, + queueCustomerCampaign, listConnectorBusinesses, processPaymentWebhook, rotateWebhookToken, diff --git a/backend/src/services/subscriptionPlans.js b/backend/src/services/subscriptionPlans.js index 272ad1d..1199fc5 100644 --- a/backend/src/services/subscriptionPlans.js +++ b/backend/src/services/subscriptionPlans.js @@ -3,11 +3,11 @@ const TRIAL_DAYS = 14; const subscriptionPlans = [ { id: 'starter', - name: 'Starter', + name: 'Grow', priceMonthly: 49, currency: 'USD', trialDays: TRIAL_DAYS, - tagline: 'For small teams that want automated review collection without extra marketing automation.', + tagline: 'For review automation that runs after setup: requests, reminders, widgets, and clean local/online routing.', limits: { monthlyReviewRequests: 250, businesses: 1, @@ -15,30 +15,33 @@ const subscriptionPlans = [ paymentConnectors: 5, }, features: [ - 'Review Flow dashboard', - 'Manual review request creation', - 'Hosted public review form', - 'Customer management', - 'Business profile management', - 'Transaction tracking', + 'Set-it-and-forget-it review request automation', + 'Local, online, or Hybrid business setup', + 'Automatic review requests from payments and orders', + 'Manual review request queue', + 'Hosted public product-review form', + 'Review monitoring dashboard and queue', + 'Embeddable social proof review widget', + 'Customer, business, and transaction management', 'Stripe, Square, PayPal, Shopify, and WooCommerce webhook intake', - 'Review request status tracking', - 'Email delivery logs', - 'Basic reporting', - 'Standard support', + 'Basic usage reporting', ], includedFeatureKeys: [ 'reviewflow_dashboard', + 'business_type_setup', + 'set_and_forget_automation', 'manual_review_requests', 'hosted_review_form', 'customer_management', 'business_management', 'transaction_tracking', + 'team_member_invitations', 'payment_webhooks', 'review_status_tracking', + 'message_preview', 'email_delivery_logs', 'basic_reporting', - 'standard_support', + 'social_proof_widgets', ], }, { @@ -47,7 +50,7 @@ const subscriptionPlans = [ priceMonthly: 99, currency: 'USD', trialDays: TRIAL_DAYS, - tagline: 'For growing businesses that want automation, AI assistance, and reputation marketing tools.', + tagline: 'For teams that want AI replies, referrals, NPS, broadcasts, rebooking campaigns, and competitor insight tools.', limits: { monthlyReviewRequests: 2500, businesses: 10, @@ -55,44 +58,44 @@ const subscriptionPlans = [ paymentConnectors: 5, }, features: [ - 'Everything in Starter', - 'Advanced automation rules', + 'Everything in Grow', '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', + 'Referral campaign queueing', + 'NPS survey campaign queueing', + 'Marketing broadcasts and repeat-business campaigns', + 'Competitor insight workspace', + '2,500 review requests per month', + 'Up to 10 business profiles', + 'Up to 10 team members', + 'Subscription usage dashboard and upgrade controls', ], includedFeatureKeys: [ 'reviewflow_dashboard', + 'business_type_setup', + 'set_and_forget_automation', 'manual_review_requests', 'hosted_review_form', 'customer_management', 'business_management', 'transaction_tracking', + 'team_member_invitations', 'payment_webhooks', 'review_status_tracking', + 'message_preview', 'email_delivery_logs', 'basic_reporting', - 'standard_support', - 'advanced_automation', - 'ai_review_replies', 'social_proof_widgets', - 'review_monitoring', + 'higher_review_request_limit', + 'higher_business_limit', + 'higher_team_member_limit', + 'subscription_usage_dashboard', + 'separate_admin_view', + 'ai_review_replies', 'referral_campaigns', - 'repeat_booking_reminders', 'nps_surveys', + 'marketing_broadcasts', + 'rebooking_campaigns', 'competitor_insights', - 'broadcast_campaigns', - 'advanced_reporting', - 'branding_customization', - 'priority_support', ], }, ]; diff --git a/backend/src/services/users.js b/backend/src/services/users.js index 2736294..638f8c3 100644 --- a/backend/src/services/users.js +++ b/backend/src/services/users.js @@ -109,7 +109,7 @@ module.exports = class UsersService { try { let users = await UsersDBApi.findBy( {id}, - {transaction}, + {transaction, currentUser}, ); if (!users) { diff --git a/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx b/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx index 06f3ff5..f0b8872 100644 --- a/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx +++ b/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx @@ -13,6 +13,8 @@ import CardBox from '../CardBox'; import FormField from '../FormField'; import { getBusinessProfileUsageLabel } from '../../helpers/businessPlanLabels'; +type BusinessType = 'local' | 'online' | 'hybrid'; + export interface ProviderConnector { key: 'stripe' | 'square' | 'paypal' | 'shopify' | 'woocommerce' | string; label: string; @@ -30,6 +32,7 @@ export interface ProviderConnector { export interface ConnectorBusiness { id: string; name?: string; + business_type?: BusinessType; google_review_link?: string; yelp_review_link?: string; facebook_review_link?: string; @@ -45,6 +48,7 @@ export interface ConnectorBusiness { export interface ConnectorFormValues { provider: string; + businessType: BusinessType; businessName: string; reviewDestination: string; reviewLink: string; @@ -57,6 +61,7 @@ interface PaymentProviderConnectorsProps { eyebrow?: string; title?: string; description?: string; + initialBusinessType?: BusinessType; onConnected?: ( business: ConnectorBusiness, connectorForm: ConnectorFormValues, @@ -82,6 +87,7 @@ type ConnectorSubscriptionStatus = { const connectorDefaults: ConnectorFormValues = { provider: 'stripe', + businessType: 'hybrid', businessName: 'Review Flow Studio', reviewDestination: 'google', reviewLink: 'https://g.page/r/example/review', @@ -95,6 +101,7 @@ const providerOptions = [ label: 'Stripe', categoryLabel: 'Payment trigger', defaultReviewDestination: 'google', + businessTypes: ['local', 'online', 'hybrid'], description: 'Connect card and checkout payments from Stripe.', }, { @@ -102,6 +109,7 @@ const providerOptions = [ label: 'PayPal', categoryLabel: 'Payment trigger', defaultReviewDestination: 'google', + businessTypes: ['local', 'online', 'hybrid'], description: 'Connect completed PayPal captures and sales.', }, { @@ -109,6 +117,7 @@ const providerOptions = [ label: 'Square', categoryLabel: 'Payment trigger', defaultReviewDestination: 'google', + businessTypes: ['local', 'hybrid'], description: 'Connect Square payment notifications.', }, { @@ -116,6 +125,7 @@ const providerOptions = [ label: 'Shopify', categoryLabel: 'Ecommerce order trigger + hosted reviews', defaultReviewDestination: 'shopify_hosted', + businessTypes: ['online', 'hybrid'], description: 'Connect paid Shopify orders; customers review products on a hosted Review Flow form.', }, @@ -124,6 +134,7 @@ const providerOptions = [ label: 'WooCommerce', categoryLabel: 'Ecommerce order trigger', defaultReviewDestination: 'trustpilot', + businessTypes: ['online', 'hybrid'], description: 'Connect WooCommerce orders from your WordPress store.', }, ]; @@ -133,6 +144,7 @@ const reviewDestinationOptions = [ key: 'google', label: 'Google', group: 'Local review destinations', + scope: 'local', mode: 'external_link', description: 'For local businesses collecting Google profile reviews.', }, @@ -140,6 +152,7 @@ const reviewDestinationOptions = [ key: 'facebook', label: 'Facebook', group: 'Local review destinations', + scope: 'local', mode: 'external_link', description: 'For local Facebook recommendations and reviews.', }, @@ -147,6 +160,7 @@ const reviewDestinationOptions = [ key: 'yelp', label: 'Yelp', group: 'Local review destinations', + scope: 'local', mode: 'external_link', description: 'For local-service Yelp review requests.', }, @@ -154,6 +168,7 @@ const reviewDestinationOptions = [ key: 'angi', label: 'Angi', group: 'Local review destinations', + scope: 'local', mode: 'external_link', description: 'For home-service Angi profile review requests.', }, @@ -161,6 +176,7 @@ const reviewDestinationOptions = [ key: 'opentable', label: 'OpenTable', group: 'Local review destinations', + scope: 'local', mode: 'external_link', description: 'For restaurant guests leaving OpenTable reviews.', }, @@ -168,6 +184,7 @@ const reviewDestinationOptions = [ key: 'shopify_hosted', label: 'Shopify hosted product review', group: 'Ecommerce review destinations', + scope: 'online', mode: 'hosted_form', description: 'Review Flow hosts the product review form after a Shopify paid order.', @@ -176,6 +193,7 @@ const reviewDestinationOptions = [ key: 'trustpilot', label: 'Trustpilot', group: 'Ecommerce review destinations', + scope: 'online', mode: 'external_link', description: 'For ecommerce brand/store review invitations.', }, @@ -183,6 +201,7 @@ const reviewDestinationOptions = [ key: 'custom', label: 'Custom review page', group: 'Custom destination', + scope: 'hybrid', mode: 'external_link', description: 'Use any review page you control.', }, @@ -203,6 +222,85 @@ const reviewDestinationGroups = [ }, ]; +const businessTypeOptions: Array<{ + key: BusinessType; + label: string; + help: string; +}> = [ + { + key: 'local', + label: 'Local / service', + help: 'Keeps payment triggers focused on local review destinations.', + }, + { + key: 'online', + label: 'Online / ecommerce', + help: 'Keeps order triggers focused on ecommerce review destinations.', + }, + { + key: 'hybrid', + label: 'Hybrid', + help: 'Shows both local and online triggers for mixed businesses.', + }, +]; + +function normalizeBusinessType(value?: string): BusinessType { + if (value === 'local' || value === 'online' || value === 'hybrid') { + return value; + } + + return 'hybrid'; +} + +function destinationAllowedForBusinessType( + businessType: BusinessType, + destination: (typeof reviewDestinationOptions)[number], +) { + if (businessType === 'hybrid' || destination.scope === 'hybrid') { + return true; + } + + return destination.scope === businessType; +} + +function getReviewDestinationsForBusinessType(businessType: BusinessType) { + return reviewDestinationOptions.filter((destination) => + destinationAllowedForBusinessType(businessType, destination), + ); +} + +function getDefaultReviewDestinationForBusinessType(businessType: BusinessType) { + if (businessType === 'online') return 'shopify_hosted'; + return 'google'; +} + +function getAllowedReviewDestination( + businessType: BusinessType, + reviewDestination?: string, +) { + const allowedDestinations = getReviewDestinationsForBusinessType(businessType); + const isAllowed = allowedDestinations.some( + (destination) => destination.key === reviewDestination, + ); + + return isAllowed + ? reviewDestination || allowedDestinations[0].key + : getDefaultReviewDestinationForBusinessType(businessType); +} + +function providerAllowedForBusinessType( + businessType: BusinessType, + provider: (typeof providerOptions)[number], +) { + return provider.businessTypes.includes(businessType); +} + +function getProvidersForBusinessType(businessType: BusinessType) { + return providerOptions.filter((provider) => + providerAllowedForBusinessType(businessType, provider), + ); +} + const providerInstructions: Record = { stripe: [ 'Stripe Dashboard → Developers → Webhooks → Add endpoint.', @@ -547,6 +645,7 @@ export default function PaymentProviderConnectors({ eyebrow = 'Order triggers and review destinations', title = 'Connect payment/ecommerce triggers without mixing local review channels', description = 'Payment and ecommerce providers trigger review requests. Review destinations decide where customers leave feedback: local profiles, ecommerce review links, or the hosted Shopify product-review form.', + initialBusinessType = 'hybrid', onConnected, }: PaymentProviderConnectorsProps) { const [connectorForm, setConnectorForm] = @@ -561,10 +660,13 @@ export default function PaymentProviderConnectors({ const [subscriptionStatus, setSubscriptionStatus] = useState(null); + const currentBusinessType = normalizeBusinessType(connectorForm.businessType); + const filteredProviderOptions = getProvidersForBusinessType(currentBusinessType); + const filteredReviewDestinationOptions = getReviewDestinationsForBusinessType(currentBusinessType); const selectedProvider = - providerOptions.find( + filteredProviderOptions.find( (provider) => provider.key === connectorForm.provider, - ) || providerOptions[0]; + ) || filteredProviderOptions[0]; const selectedSetup = providerSetupDetails[selectedProvider.key] || providerSetupDetails.stripe; const selectedApiBackup = @@ -576,9 +678,9 @@ export default function PaymentProviderConnectors({ ? 'shopify_hosted' : connectorForm.reviewDestination; const selectedReviewDestination = - reviewDestinationOptions.find( + filteredReviewDestinationOptions.find( (destination) => destination.key === effectiveReviewDestination, - ) || reviewDestinationOptions[0]; + ) || filteredReviewDestinationOptions[0]; const isHostedReviewDestination = selectedReviewDestination.mode === 'hosted_form'; @@ -601,9 +703,9 @@ export default function PaymentProviderConnectors({ return { connectedCount, - totalCount: providers.length || providerOptions.length, + totalCount: providers.length || filteredProviderOptions.length, }; - }, [connectors]); + }, [connectors, filteredProviderOptions.length]); const selectedWebhookTargets = useMemo( () => @@ -627,22 +729,46 @@ export default function PaymentProviderConnectors({ key: keyof ConnectorFormValues, value: string, ) => { - setConnectorForm((current) => ({ ...current, [key]: value })); + setConnectorForm((current) => { + if (key === 'businessType') { + const businessType = normalizeBusinessType(value); + const providers = getProvidersForBusinessType(businessType); + const provider = providers.find( + (providerOption) => providerOption.key === current.provider, + ) || providers[0]; + + return { + ...current, + businessType, + provider: provider.key, + reviewDestination: getAllowedReviewDestination( + businessType, + provider.defaultReviewDestination === 'shopify_hosted' + ? 'shopify_hosted' + : current.reviewDestination, + ), + }; + } + + return { ...current, [key]: value }; + }); }; const updateSelectedProvider = (providerKey: string) => { const provider = - providerOptions.find( + filteredProviderOptions.find( (providerOption) => providerOption.key === providerKey, - ) || providerOptions[0]; + ) || filteredProviderOptions[0]; setConnectorForm((current) => ({ ...current, provider: provider.key, - reviewDestination: + reviewDestination: getAllowedReviewDestination( + currentBusinessType, provider.defaultReviewDestination === 'shopify_hosted' ? 'shopify_hosted' : current.reviewDestination, + ), })); }; @@ -690,6 +816,10 @@ export default function PaymentProviderConnectors({ loadSubscriptionStatus(); }, []); + useEffect(() => { + updateConnectorForm('businessType', normalizeBusinessType(initialBusinessType)); + }, [initialBusinessType]); + const handleConnectorSubmit = async (event: FormEvent) => { event.preventDefault(); setIsConnectorSubmitting(true); @@ -927,8 +1057,7 @@ export default function PaymentProviderConnectors({ 1. Select provider

- Pick Stripe, PayPal, Square, Shopify, or WooCommerce from the - dropdown. + Pick the relevant provider from the filtered dropdown; Local, Online, and Hybrid setups show different choices.

@@ -957,14 +1086,24 @@ export default function PaymentProviderConnectors({ className='mb-6 rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-dark-700 dark:bg-dark-800' > option.key === currentBusinessType)?.help} > + selectBusiness(event.target.value)}> + {businesses.map((business) => ( + + ))} + + + )} + +
+ option.key === currentBusinessType)?.help}> + updateSettings('businessName', event.target.value)} + placeholder='Business name' + /> + + + + + + updateSettings('delayDays', event.target.value)} + placeholder='Initial delay days' + /> + updateSettings('followupDelayDays', event.target.value)} + placeholder='Follow-up delay days' + /> + updateSettings('maxFollowups', event.target.value)} + placeholder='Max follow-ups' + /> + + +
+ {[ + ['followupEnabled', 'Follow-ups', 'Automatically prepare follow-up handoffs for customers who have not clicked.'], + ['socialWidgetEnabled', 'Social proof widget', 'Show verified hosted reviews on websites and landing pages.'], + ['aiReplyEnabled', 'AI replies (Pro)', 'Generate review replies using the existing AI proxy.'], + ['referralEnabled', 'Referrals (Pro)', 'Queue referral campaign messages for customers.'], + ['npsEnabled', 'NPS surveys (Pro)', 'Queue NPS survey outreach.'], + ['broadcastEnabled', 'Broadcasts (Pro)', 'Queue marketing broadcasts.'], + ['rebookingEnabled', 'Rebooking (Pro)', 'Queue repeat-business campaigns.'], + ['competitorInsightsEnabled', 'Competitor insights (Pro)', 'Save competitors and build an action checklist.'], + ].map(([key, label, help]) => ( + + ))} +
+ + + updateSettings('referralOffer', event.target.value)} placeholder='Referral offer' /> + updateSettings('npsQuestion', event.target.value)} placeholder='NPS question' /> + + +