diff --git a/backend/src/routes/reviewflow-public.js b/backend/src/routes/reviewflow-public.js
index baf6b1d..5dcc16f 100644
--- a/backend/src/routes/reviewflow-public.js
+++ b/backend/src/routes/reviewflow-public.js
@@ -1,5 +1,6 @@
const express = require('express');
const ReviewFlowService = require('../services/reviewflow');
+const ReviewFlowOAuthService = require('../services/reviewflow-oauth');
const wrapAsync = require('../helpers').wrapAsync;
const router = express.Router();
@@ -20,6 +21,28 @@ router.post('/reviews/:trackingToken', wrapAsync(async (req, res) => {
}));
+
+router.use('/oauth/:provider/callback', express.urlencoded({ extended: false }));
+
+function sendOAuthCallback(req, res) {
+ const result = ReviewFlowOAuthService.handleOAuthCallback(
+ req.params.provider,
+ ReviewFlowOAuthService.getCallbackInput(req),
+ );
+
+ res.status(result.status === 'error' ? 400 : 200)
+ .type('html')
+ .send(ReviewFlowOAuthService.renderCallbackHtml(result));
+}
+
+router.get('/oauth/:provider/callback', wrapAsync(async (req, res) => {
+ sendOAuthCallback(req, res);
+}));
+
+router.post('/oauth/:provider/callback', wrapAsync(async (req, res) => {
+ sendOAuthCallback(req, res);
+}));
+
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&')
diff --git a/backend/src/routes/reviewflow.js b/backend/src/routes/reviewflow.js
index 363526f..a076e3b 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 ReviewFlowOAuthService = require('../services/reviewflow-oauth');
const SubscriptionService = require('../services/subscription');
const wrapAsync = require('../helpers').wrapAsync;
@@ -44,6 +45,8 @@ const REVIEW_LINK_FIELDS = {
custom: 'custom_review_link',
};
+const DEFAULT_REVIEW_PLATFORMS = new Set(['google', 'yelp', 'facebook', 'custom']);
+
function normalizeReviewDestination(value) {
const destination = normalizeString(value).toLowerCase();
@@ -105,6 +108,34 @@ function normalizeOptionalUrl(value, message) {
return normalized;
}
+function normalizeOptionalDate(value, message) {
+ const normalized = normalizeString(value);
+
+ if (!normalized) {
+ return null;
+ }
+
+ const date = new Date(normalized);
+
+ if (Number.isNaN(date.getTime())) {
+ const error = new Error(message);
+ error.code = 400;
+ throw error;
+ }
+
+ return date;
+}
+
+function normalizeDefaultReviewPlatform(value, fallback = 'google') {
+ const platform = normalizeString(value || fallback).toLowerCase();
+
+ if (DEFAULT_REVIEW_PLATFORMS.has(platform)) {
+ return platform;
+ }
+
+ return DEFAULT_REVIEW_PLATFORMS.has(fallback) ? fallback : 'google';
+}
+
function normalizeOptionalEmail(value) {
const normalized = normalizeString(value).toLowerCase();
@@ -460,6 +491,7 @@ router.put('/growth-tools/business', wrapAsync(async (req, res) => {
const body = req.body || {};
const businessId = normalizeString(body.businessId || body.id);
const businessName = normalizeString(body.businessName || body.name || 'Review Flow Business');
+ const ownerId = normalizeString(body.ownerId || body.owner || currentUser.id) || currentUser.id;
let business = businessId
? await db.businesses.findOne({ where: { id: businessId, createdById: currentUser.id } })
: await db.businesses.findOne({ where: { name: businessName, createdById: currentUser.id } });
@@ -476,10 +508,10 @@ router.put('/growth-tools/business', wrapAsync(async (req, res) => {
name: businessName,
business_type: normalizeBusinessType(body.businessType || body.business_type, 'hybrid'),
automation_mode: 'set_and_forget',
- is_active: true,
+ is_active: parseBoolean(body.isActive ?? body.is_active, true),
createdById: currentUser.id,
updatedById: currentUser.id,
- ownerId: currentUser.id,
+ ownerId,
});
}
@@ -493,6 +525,7 @@ router.put('/growth-tools/business', wrapAsync(async (req, res) => {
}
const aiReplyEnabled = parseBoolean(body.aiReplyEnabled ?? body.ai_reply_enabled, business.ai_reply_enabled);
+ const stripeConnectedAtInput = body.stripeConnectedAt ?? body.stripe_connected_at;
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);
@@ -525,6 +558,7 @@ router.put('/growth-tools/business', wrapAsync(async (req, res) => {
name: businessName || business.name,
business_type: businessType,
automation_mode: normalizeString(body.automationMode || body.automation_mode) || 'set_and_forget',
+ ownerId,
review_destination: reviewDestination,
google_review_link: normalizeOptionalUrl(body.googleReviewLink ?? body.google_review_link ?? business.google_review_link, 'Google review link must be a valid URL.'),
yelp_review_link: normalizeOptionalUrl(body.yelpReviewLink ?? body.yelp_review_link ?? business.yelp_review_link, 'Yelp review link must be a valid URL.'),
@@ -549,7 +583,13 @@ router.put('/growth-tools/business', wrapAsync(async (req, res) => {
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',
...brandedMessagingPayload,
- is_active: true,
+ is_active: parseBoolean(body.isActive ?? body.is_active, business.is_active !== false),
+ stripe_account_reference: normalizeString(body.stripeAccountReference ?? body.stripe_account_reference ?? business.stripe_account_reference),
+ stripe_connected: parseBoolean(body.stripeConnected ?? body.stripe_connected, business.stripe_connected),
+ stripe_connected_at: stripeConnectedAtInput === undefined
+ ? business.stripe_connected_at
+ : normalizeOptionalDate(stripeConnectedAtInput, 'Stripe connected at must be a valid date and time.'),
+ default_review_platform: normalizeDefaultReviewPlatform(body.defaultReviewPlatform ?? body.default_review_platform ?? business.default_review_platform, business.default_review_platform || 'google'),
updatedById: currentUser.id,
};
@@ -559,6 +599,24 @@ router.put('/growth-tools/business', wrapAsync(async (req, res) => {
res.status(200).send({ business: ReviewFlowService.serializeBusiness(req, refreshedBusiness) });
}));
+
+router.get('/oauth/:provider/status', wrapAsync(async (req, res) => {
+ const status = ReviewFlowOAuthService.getOAuthStatus(req.params.provider, req);
+
+ res.status(200).send(status);
+}));
+
+router.post('/oauth/:provider/start', wrapAsync(async (req, res) => {
+ const start = ReviewFlowOAuthService.createAuthorizationStart(
+ req.params.provider,
+ req.currentUser,
+ req.body || {},
+ req,
+ );
+
+ res.status(200).send(start);
+}));
+
router.post('/growth-tools/broadcast', wrapAsync(async (req, res) => {
const campaignType = normalizeString(req.body?.campaignType || 'broadcast');
const featureByCampaign = {
diff --git a/backend/src/services/reviewflow-oauth.js b/backend/src/services/reviewflow-oauth.js
new file mode 100644
index 0000000..f3b808a
--- /dev/null
+++ b/backend/src/services/reviewflow-oauth.js
@@ -0,0 +1,448 @@
+const crypto = require('crypto');
+const config = require('../config');
+const ReviewFlowService = require('./reviewflow');
+
+const STATE_TTL_MS = 30 * 60 * 1000;
+
+const OAUTH_PROVIDER_DEFINITIONS = {
+ stripe: {
+ requiredEnvGroups: [
+ { key: 'clientId', names: ['STRIPE_CONNECT_CLIENT_ID', 'STRIPE_CLIENT_ID'] },
+ { key: 'clientSecret', names: ['STRIPE_CONNECT_CLIENT_SECRET', 'STRIPE_SECRET_KEY'] },
+ ],
+ getAuthorizeBaseUrl: () => process.env.STRIPE_CONNECT_AUTHORIZE_URL || 'https://connect.stripe.com/oauth/authorize',
+ getScopes: () => process.env.STRIPE_CONNECT_SCOPE || 'read_write',
+ buildAuthorizationUrl({ credentials, callbackUrl, state }) {
+ return appendQuery(this.getAuthorizeBaseUrl(), {
+ response_type: 'code',
+ client_id: credentials.clientId,
+ scope: this.getScopes(),
+ redirect_uri: callbackUrl,
+ state,
+ });
+ },
+ },
+ square: {
+ requiredEnvGroups: [
+ { key: 'clientId', names: ['SQUARE_APP_ID', 'SQUARE_CLIENT_ID'] },
+ { key: 'clientSecret', names: ['SQUARE_APP_SECRET', 'SQUARE_CLIENT_SECRET'] },
+ ],
+ getAuthorizeBaseUrl: () => {
+ if (process.env.SQUARE_AUTHORIZE_URL) return process.env.SQUARE_AUTHORIZE_URL;
+ return process.env.SQUARE_ENVIRONMENT === 'sandbox'
+ ? 'https://connect.squareupsandbox.com/oauth2/authorize'
+ : 'https://connect.squareup.com/oauth2/authorize';
+ },
+ getScopes: () => process.env.SQUARE_OAUTH_SCOPES || 'CUSTOMERS_READ ORDERS_READ PAYMENTS_READ MERCHANT_PROFILE_READ',
+ buildAuthorizationUrl({ credentials, callbackUrl, state }) {
+ return appendQuery(this.getAuthorizeBaseUrl(), {
+ client_id: credentials.clientId,
+ response_type: 'code',
+ scope: this.getScopes(),
+ redirect_uri: callbackUrl,
+ state,
+ });
+ },
+ },
+ paypal: {
+ requiredEnvGroups: [
+ { key: 'clientId', names: ['PAYPAL_CLIENT_ID'] },
+ { key: 'clientSecret', names: ['PAYPAL_CLIENT_SECRET'] },
+ ],
+ getAuthorizeBaseUrl: () => {
+ if (process.env.PAYPAL_AUTHORIZE_URL) return process.env.PAYPAL_AUTHORIZE_URL;
+ return process.env.PAYPAL_ENVIRONMENT === 'sandbox'
+ ? 'https://www.sandbox.paypal.com/connect'
+ : 'https://www.paypal.com/connect';
+ },
+ getScopes: () => process.env.PAYPAL_OAUTH_SCOPES || 'openid profile email',
+ buildAuthorizationUrl({ credentials, callbackUrl, state }) {
+ return appendQuery(this.getAuthorizeBaseUrl(), {
+ flowEntry: 'static',
+ client_id: credentials.clientId,
+ response_type: 'code',
+ scope: this.getScopes(),
+ redirect_uri: callbackUrl,
+ state,
+ });
+ },
+ },
+ shopify: {
+ requiredEnvGroups: [
+ { key: 'clientId', names: ['SHOPIFY_CLIENT_ID'] },
+ { key: 'clientSecret', names: ['SHOPIFY_CLIENT_SECRET'] },
+ ],
+ getScopes: () => process.env.SHOPIFY_OAUTH_SCOPES || 'read_orders,read_customers',
+ buildAuthorizationUrl({ credentials, callbackUrl, state, body }) {
+ const shop = normalizeShopifyShop(body.shop || body.shopDomain || body.storeDomain || body.accountReference);
+
+ return appendQuery(`https://${shop}/admin/oauth/authorize`, {
+ client_id: credentials.clientId,
+ scope: this.getScopes(),
+ redirect_uri: callbackUrl,
+ state,
+ });
+ },
+ },
+ woocommerce: {
+ requiredEnvGroups: [],
+ buildAuthorizationUrl({ callbackUrl, state, body, req }) {
+ const storeOrigin = normalizeStoreOrigin(
+ body.storeUrl || body.shop || body.shopDomain || body.storeDomain || body.accountReference,
+ 'Enter your WooCommerce store URL in the account/store label field before starting OAuth backup.',
+ );
+
+ return appendQuery(`${storeOrigin}/wc-auth/v1/authorize`, {
+ app_name: process.env.WOOCOMMERCE_APP_NAME || 'ReviewFlow',
+ scope: process.env.WOOCOMMERCE_OAUTH_SCOPE || 'read_write',
+ user_id: state,
+ return_url: getAppReturnUrl(req, 'woocommerce'),
+ callback_url: callbackUrl,
+ });
+ },
+ },
+};
+
+function normalizeString(value) {
+ return typeof value === 'string' ? value.trim() : '';
+}
+
+function httpError(message, code = 400) {
+ const error = new Error(message);
+ error.code = code;
+ return error;
+}
+
+function appendQuery(baseUrl, params) {
+ const url = new URL(baseUrl);
+
+ Object.entries(params).forEach(([key, value]) => {
+ const normalized = normalizeString(value);
+
+ if (normalized) {
+ url.searchParams.set(key, normalized);
+ }
+ });
+
+ return url.toString();
+}
+
+function getEnvValue(names) {
+ const foundName = names.find((name) => normalizeString(process.env[name]));
+ return foundName ? normalizeString(process.env[foundName]) : '';
+}
+
+function resolveCredentials(definition) {
+ const credentials = {};
+ const missing = [];
+
+ definition.requiredEnvGroups.forEach((group) => {
+ const value = getEnvValue(group.names);
+
+ if (value) {
+ credentials[group.key] = value;
+ return;
+ }
+
+ missing.push(group.names.join(' or '));
+ });
+
+ return { credentials, missing };
+}
+
+function getRequestOrigin(req) {
+ const forwardedProto = normalizeString(req.headers['x-forwarded-proto']).split(',')[0];
+ const proto = forwardedProto || req.protocol || 'https';
+ const forwardedHost = normalizeString(req.headers['x-forwarded-host']).split(',')[0];
+ const host = forwardedHost || req.get('host');
+
+ return `${proto}://${host}`;
+}
+
+function getCallbackUrl(req, provider) {
+ return `${getRequestOrigin(req)}/api/reviewflow-public/oauth/${provider}/callback`;
+}
+
+function getAppReturnUrl(req, provider) {
+ return `${getRequestOrigin(req)}/connect?payment_oauth=${provider}`;
+}
+
+function base64UrlEncode(value) {
+ return Buffer.from(value)
+ .toString('base64')
+ .replace(/=/g, '')
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_');
+}
+
+function base64UrlDecode(value) {
+ const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
+ const padding = normalized.length % 4 ? '='.repeat(4 - (normalized.length % 4)) : '';
+
+ return Buffer.from(`${normalized}${padding}`, 'base64').toString('utf8');
+}
+
+function signStatePayload(encodedPayload) {
+ return crypto
+ .createHmac('sha256', config.secret_key)
+ .update(encodedPayload)
+ .digest('base64')
+ .replace(/=/g, '')
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_');
+}
+
+function safeEqual(left, right) {
+ const leftBuffer = Buffer.from(left);
+ const rightBuffer = Buffer.from(right);
+
+ return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer);
+}
+
+function createStateToken(payload) {
+ const encodedPayload = base64UrlEncode(JSON.stringify({
+ ...payload,
+ issuedAt: Date.now(),
+ nonce: crypto.randomBytes(12).toString('hex'),
+ }));
+ const signature = signStatePayload(encodedPayload);
+
+ return `${encodedPayload}.${signature}`;
+}
+
+function verifyStateToken(state) {
+ const normalizedState = normalizeString(state);
+ const [encodedPayload, signature] = normalizedState.split('.');
+
+ if (!encodedPayload || !signature || !safeEqual(signature, signStatePayload(encodedPayload))) {
+ throw httpError('OAuth state is invalid. Please restart the payment provider connection from ReviewFlow.', 400);
+ }
+
+ let payload;
+
+ try {
+ payload = JSON.parse(base64UrlDecode(encodedPayload));
+ } catch (parseError) {
+ console.error('Failed to parse ReviewFlow OAuth state:', parseError);
+ throw httpError('OAuth state could not be read. Please restart the payment provider connection from ReviewFlow.', 400);
+ }
+
+ if (!payload.issuedAt || Date.now() - payload.issuedAt > STATE_TTL_MS) {
+ throw httpError('OAuth state expired. Please restart the payment provider connection from ReviewFlow.', 400);
+ }
+
+ return payload;
+}
+
+function normalizeShopifyShop(value) {
+ const rawValue = normalizeString(value)
+ .toLowerCase()
+ .replace(/^https?:\/\//, '')
+ .split('/')[0];
+
+ if (!rawValue) {
+ throw httpError('Enter your Shopify myshopify.com store domain in the account/store label field before starting OAuth backup.', 400);
+ }
+
+ const shop = rawValue.includes('.') ? rawValue : `${rawValue}.myshopify.com`;
+
+ if (!/^[a-z0-9][a-z0-9.-]*\.myshopify\.com$/.test(shop)) {
+ throw httpError('Shopify OAuth needs your myshopify.com store domain, such as your-store.myshopify.com.', 400);
+ }
+
+ return shop;
+}
+
+function normalizeStoreOrigin(value, message) {
+ const rawValue = normalizeString(value);
+
+ if (!rawValue) {
+ throw httpError(message, 400);
+ }
+
+ const valueWithProtocol = /^https?:\/\//i.test(rawValue) ? rawValue : `https://${rawValue}`;
+
+ try {
+ const parsed = new URL(valueWithProtocol);
+ return parsed.origin;
+ } catch {
+ throw httpError('Enter a valid WooCommerce store URL, such as https://store.example.com.', 400);
+ }
+}
+
+function getProviderOAuthDefinition(providerName) {
+ const providerConfig = ReviewFlowService.getProviderConfig(providerName);
+ const definition = OAUTH_PROVIDER_DEFINITIONS[providerConfig.provider];
+
+ if (!definition) {
+ throw httpError('OAuth backup is not available for this payment provider yet.', 400);
+ }
+
+ return { providerConfig, definition };
+}
+
+function getOAuthStatus(providerName, req) {
+ const { providerConfig, definition } = getProviderOAuthDefinition(providerName);
+ const { missing } = resolveCredentials(definition);
+
+ return {
+ provider: providerConfig.provider,
+ label: providerConfig.label,
+ mode: 'backup',
+ primaryConnection: 'webhook',
+ configured: missing.length === 0,
+ missingConfiguration: missing,
+ callbackUrl: getCallbackUrl(req, providerConfig.provider),
+ message: missing.length === 0
+ ? `${providerConfig.label} OAuth backup can be started. Webhook remains the primary connection.`
+ : `OAuth backup for ${providerConfig.label} is ready, but provider credentials are not configured yet. Add ${missing.join(', ')} to the backend environment first.`,
+ };
+}
+
+function createAuthorizationStart(providerName, currentUser, body, req) {
+ const { providerConfig, definition } = getProviderOAuthDefinition(providerName);
+ const { credentials, missing } = resolveCredentials(definition);
+ const callbackUrl = getCallbackUrl(req, providerConfig.provider);
+
+ if (missing.length > 0) {
+ throw httpError(
+ `OAuth backup for ${providerConfig.label} is ready, but provider credentials are not configured yet. Add ${missing.join(', ')} to the backend environment, then try again. Webhooks remain the primary payment trigger. Callback URL: ${callbackUrl}`,
+ 400,
+ );
+ }
+
+ const state = createStateToken({
+ provider: providerConfig.provider,
+ userId: currentUser.id,
+ businessId: normalizeString(body.businessId),
+ reason: normalizeString(body.reason) || 'webhook_backup',
+ accountReference: normalizeString(body.accountReference),
+ });
+ const authorizationUrl = definition.buildAuthorizationUrl({
+ credentials,
+ callbackUrl,
+ state,
+ body: body || {},
+ req,
+ });
+
+ return {
+ provider: providerConfig.provider,
+ label: providerConfig.label,
+ mode: 'backup',
+ primaryConnection: 'webhook',
+ authorizationUrl,
+ callbackUrl,
+ message: `Opening ${providerConfig.label} OAuth as a backup. Webhooks remain the primary payment trigger.`,
+ };
+}
+
+function getCallbackInput(req) {
+ return {
+ ...(req.query || {}),
+ ...(req.body || {}),
+ };
+}
+
+function readProviderError(input) {
+ const error = normalizeString(input.error || input.error_description);
+
+ if (!error) return '';
+
+ return error;
+}
+
+function handleOAuthCallback(providerName, input) {
+ const providerConfig = ReviewFlowService.getProviderConfig(providerName);
+ const providerError = readProviderError(input);
+ const stateToken = normalizeString(input.state || input.user_id);
+
+ if (!stateToken) {
+ throw httpError('OAuth callback is missing state. Please restart the payment provider connection from ReviewFlow.', 400);
+ }
+
+ const state = verifyStateToken(stateToken);
+
+ if (state.provider !== providerConfig.provider) {
+ throw httpError('OAuth callback provider does not match the original ReviewFlow connection request.', 400);
+ }
+
+ if (providerError) {
+ return {
+ provider: providerConfig.provider,
+ label: providerConfig.label,
+ status: 'error',
+ message: `${providerConfig.label} did not complete OAuth authorization: ${providerError}`,
+ primaryConnection: 'webhook',
+ backupConnection: 'oauth',
+ };
+ }
+
+ const codeReceived = Boolean(normalizeString(input.code));
+ const wooCommerceCredentialsReceived = providerConfig.provider === 'woocommerce'
+ && Boolean(normalizeString(input.consumer_key) && normalizeString(input.consumer_secret));
+
+ return {
+ provider: providerConfig.provider,
+ label: providerConfig.label,
+ status: codeReceived || wooCommerceCredentialsReceived ? 'received' : 'pending',
+ codeReceived,
+ credentialsReceived: wooCommerceCredentialsReceived,
+ primaryConnection: 'webhook',
+ backupConnection: 'oauth',
+ message: codeReceived || wooCommerceCredentialsReceived
+ ? `${providerConfig.label} returned OAuth authorization successfully. Webhook remains primary; this backup callback is ready for token exchange/storage when provider secrets and credential storage are finalized.`
+ : `${providerConfig.label} callback was received, but no authorization code was included. Please restart the OAuth backup connection from ReviewFlow.`,
+ };
+}
+
+function renderCallbackHtml(result) {
+ const isSuccess = result.status === 'received';
+ const title = isSuccess ? 'OAuth backup received' : 'OAuth backup needs attention';
+ const accent = isSuccess ? '#059669' : '#dc2626';
+
+ return `
+
+
+
+
+ ${escapeHtml(title)}
+
+
+
+
+ ReviewFlow payment backup
+ ${escapeHtml(title)}
+ ${escapeHtml(result.message)}
+ ${escapeHtml(result.label)} · Webhook primary · OAuth backup
+
+ Return to payment setup
+
+
+`;
+}
+
+function escapeHtml(value) {
+ return String(value || '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+module.exports = {
+ createAuthorizationStart,
+ getCallbackInput,
+ getOAuthStatus,
+ handleOAuthCallback,
+ renderCallbackHtml,
+};
diff --git a/backend/src/services/reviewflow.js b/backend/src/services/reviewflow.js
index d2b2b93..31e8869 100644
--- a/backend/src/services/reviewflow.js
+++ b/backend/src/services/reviewflow.js
@@ -1013,6 +1013,11 @@ function serializeBusiness(req, business) {
return {
id: business.id,
name: business.name,
+ ownerId: business.ownerId || '',
+ is_active: business.is_active !== false,
+ stripe_account_reference: business.stripe_account_reference || '',
+ stripe_connected: Boolean(business.stripe_connected),
+ stripe_connected_at: business.stripe_connected_at || null,
business_type: normalizeBusinessType(business.business_type),
automation_mode: business.automation_mode || 'set_and_forget',
followup_enabled: business.followup_enabled !== false,
diff --git a/frontend/src/components/FloatingAiHelper.tsx b/frontend/src/components/FloatingAiHelper.tsx
new file mode 100644
index 0000000..205c0d4
--- /dev/null
+++ b/frontend/src/components/FloatingAiHelper.tsx
@@ -0,0 +1,209 @@
+import {
+ mdiChatQuestionOutline,
+ mdiClose,
+ mdiRobotHappyOutline,
+ mdiSend,
+} from '@mdi/js';
+import { useRouter } from 'next/router';
+import React, { FormEvent, useMemo, useState } from 'react';
+import BaseIcon from './BaseIcon';
+import { aiResponse } from '../stores/openAiSlice';
+import { useAppDispatch, useAppSelector } from '../stores/hooks';
+
+type ChatMessage = {
+ role: 'assistant' | 'user';
+ content: string;
+};
+
+function extractAiResponseText(response: any) {
+ if (typeof response?.output_text === 'string') return response.output_text;
+ if (typeof response?.text === 'string') return response.text;
+
+ const output = Array.isArray(response?.output)
+ ? response.output
+ : Array.isArray(response?.data?.output)
+ ? response.data.output
+ : [];
+
+ for (const item of output) {
+ if (item?.type === 'message' && Array.isArray(item.content)) {
+ const textContent = item.content.find((contentItem) => contentItem?.type === 'output_text' && typeof contentItem.text === 'string');
+
+ if (textContent?.text) return textContent.text;
+ }
+ }
+
+ return '';
+}
+
+function buildSystemPrompt(pathname: string, userName: string) {
+ return [
+ 'You are the ReviewFlow in-app AI setup assistant.',
+ 'ReviewFlow is a SaaS/CRM/admin app for automating review requests after payments, orders, jobs, or customer transactions. The current business context is logistics/transportation, but users may configure any business type.',
+ 'Important app areas: Setup, Connect, Review Flow, Growth Tools, Businesses, Customers, Transactions, Review Requests, Stripe Events, Cron Runs, Subscription, Users, Roles, and Permissions.',
+ 'Payment setup flow: select provider, create/copy ReviewFlow webhook URL, paste that webhook URL into the provider dashboard, paste the same URL back into the app to confirm, run a test transaction, wait for status Complete, then click Finish setup to close into the summary box.',
+ 'Review output setup flow: choose business type, choose review destinations such as Google/Facebook/Yelp/Angi/OpenTable/Trustpilot/custom/Shopify hosted, add required links, then save/finish selections.',
+ 'When helping, give short step-by-step guidance. Explain basic concepts kindly because the user may be new to web development. Do not claim that external OAuth is fully connected unless the app screen says it is. If webhook setup fails, suggest checking provider, exact URL match, business type/provider compatibility, and using the test transaction window.',
+ `Current page path: ${pathname}.`,
+ userName ? `Current signed-in user name: ${userName}.` : '',
+ ].filter(Boolean).join('\n');
+}
+
+export default function FloatingAiHelper() {
+ const dispatch = useAppDispatch();
+ const router = useRouter();
+ const { currentUser } = useAppSelector((state) => state.auth);
+ const { isAskingResponse } = useAppSelector((state) => state.openAi);
+ const [isOpen, setIsOpen] = useState(false);
+ const [dismissedNudge, setDismissedNudge] = useState(false);
+ const [draft, setDraft] = useState('');
+ const [isSending, setIsSending] = useState(false);
+ const [messages, setMessages] = useState([
+ {
+ role: 'assistant',
+ content: 'Hi — I can help you set up ReviewFlow. Ask me about webhook URLs, test transactions, review destinations, Growth Tools, or what to do next.',
+ },
+ ]);
+
+ const isSetupRoute = useMemo(
+ () => ['/setup', '/connect', '/growth-tools', '/reviewflow'].some((path) => router.pathname.startsWith(path)),
+ [router.pathname],
+ );
+ const userName = [currentUser?.firstName, currentUser?.lastName].filter(Boolean).join(' ');
+
+ const submitMessage = async (event: FormEvent) => {
+ event.preventDefault();
+
+ const question = draft.trim();
+
+ if (!question || isSending) return;
+
+ const nextMessages: ChatMessage[] = [...messages, { role: 'user', content: question }];
+ const payload = {
+ input: [
+ { role: 'system', content: buildSystemPrompt(router.pathname, userName) },
+ ...nextMessages.slice(-8).map((message) => ({ role: message.role, content: message.content })),
+ ],
+ options: { poll_interval: 5, poll_timeout: 300 },
+ };
+
+ setMessages(nextMessages);
+ setDraft('');
+ setIsSending(true);
+
+ try {
+ const response = await dispatch(aiResponse(payload)).unwrap();
+ const answer = extractAiResponseText(response) || 'I received a response, but could not find readable assistant text. Please try asking again.';
+
+ setMessages([...nextMessages, { role: 'assistant', content: answer }]);
+ } catch (requestError) {
+ console.error('Floating AI helper request failed:', { payload, error: requestError });
+ setMessages([
+ ...nextMessages,
+ {
+ role: 'assistant',
+ content: 'I could not reach the AI helper right now. Please try again in a moment, and check that you are still signed in.',
+ },
+ ]);
+ throw requestError;
+ } finally {
+ setIsSending(false);
+ }
+ };
+
+ return (
+
+ {!isOpen && isSetupRoute && !dismissedNudge ? (
+
+
+
+
+
Need setup help?
+
Click the AI helper any time for first-this-then-this guidance.
+
+
+
+
+ ) : null}
+
+ {isOpen ? (
+
+
+
+
+
+
+
+
ReviewFlow AI Helper
+
Ask about setup, payments, reviews, or next steps.
+
+
+
+
+
+
+ {messages.map((message, index) => (
+
+ {message.content}
+
+ ))}
+ {isSending || isAskingResponse ? (
+
+ Thinking…
+
+ ) : null}
+
+
+
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx b/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx
index f0f2bae..2b24297 100644
--- a/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx
+++ b/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx
@@ -1,19 +1,23 @@
import {
mdiAlertCircleOutline,
+ mdiCashCheck,
mdiCheckCircleOutline,
+ mdiClipboardTextOutline,
mdiContentCopy,
- mdiCreditCardOutline,
- mdiRefresh,
+ mdiHelpCircleOutline,
+ mdiLinkVariant,
+ mdiPlayCircleOutline,
+ mdiRobotHappyOutline,
mdiWebhook,
} from '@mdi/js';
import axios from 'axios';
-import React, { FormEvent, useEffect, useMemo, useState } from 'react';
+import React, { useEffect, useMemo, useState } from 'react';
import BaseButton from '../BaseButton';
+import BaseIcon from '../BaseIcon';
import CardBox from '../CardBox';
import FormField from '../FormField';
-import { getBusinessProfileUsageLabel } from '../../helpers/businessPlanLabels';
-type BusinessType = 'local' | 'online' | 'hybrid';
+export type BusinessType = 'local' | 'online' | 'hybrid';
export interface ProviderConnector {
key: 'stripe' | 'square' | 'paypal' | 'shopify' | 'woocommerce' | string;
@@ -61,1693 +65,1194 @@ interface PaymentProviderConnectorsProps {
eyebrow?: string;
title?: string;
description?: string;
+ initialBusinessId?: string;
+ initialBusinessName?: string;
initialBusinessType?: BusinessType;
+ initialReviewDestination?: string;
+ initialReviewLink?: string;
+ initialDelayDays?: string | number;
onConnected?: (
business: ConnectorBusiness,
connectorForm: ConnectorFormValues,
) => void | Promise;
}
-type ConnectorSubscriptionStatus = {
- subscription: {
- planId: string;
- planName: string;
- effectiveStatus: string;
- isActive: boolean;
- };
- usage: {
- businesses: number;
- paymentConnectors: number;
- };
- limits: {
- businesses: number;
- paymentConnectors: number;
+type PaymentProviderKey = 'stripe' | 'square' | 'paypal' | 'shopify' | 'woocommerce';
+
+type PaymentProviderSetup = {
+ key: PaymentProviderKey;
+ label: string;
+ eventLabel: string;
+ defaultReviewDestination: string;
+ providerArea: string;
+ instructions: string[];
+ testWindow: {
+ title: string;
+ steps: string[];
+ success: string;
};
+ summary: string;
};
-const connectorDefaults: ConnectorFormValues = {
- provider: 'stripe',
- businessType: 'hybrid',
- businessName: 'Review Flow Studio',
- reviewDestination: 'google',
- reviewLink: 'https://g.page/r/example/review',
- delayDays: '7',
- accountReference: '',
+type CompletedConnection = {
+ providerKey: PaymentProviderKey;
+ providerLabel: string;
+ status: string;
+ completedAt: string;
+ summary: string;
+ testResult: string;
+ webhookUrl: string;
};
-const providerOptions = [
+type TestTransaction = {
+ id: string;
+ providerReference: string;
+ customer: string;
+ email: string;
+ amount: string;
+ eventType: string;
+ receivedAt: string;
+ message: string;
+};
+
+type TestPayload = {
+ payload: Record;
+ headers?: Record;
+ transaction: Omit;
+};
+
+type OauthBackupReason = 'failed_test' | 'completed_webhook';
+
+const copyTextToClipboard = async (text: string) => {
+ let clipboardApiError: unknown;
+
+ if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
+ try {
+ await navigator.clipboard.writeText(text);
+ return;
+ } catch (copyError) {
+ clipboardApiError = copyError;
+ console.warn('Clipboard API copy failed; trying legacy copy fallback.', copyError);
+ }
+ }
+
+ if (typeof document === 'undefined') {
+ throw clipboardApiError || new Error('Clipboard copy is not available in this environment.');
+ }
+
+ const textarea = document.createElement('textarea');
+ textarea.value = text;
+ textarea.setAttribute('readonly', 'true');
+ textarea.style.position = 'fixed';
+ textarea.style.left = '-9999px';
+ textarea.style.top = '0';
+ textarea.style.opacity = '0';
+
+ document.body.appendChild(textarea);
+ textarea.focus();
+ textarea.select();
+
+ try {
+ const copied = document.execCommand('copy');
+
+ if (!copied) {
+ throw new Error('Legacy clipboard copy command failed.');
+ }
+ } catch (fallbackError) {
+ console.error('Failed to copy webhook URL with the legacy fallback:', fallbackError);
+
+ if (clipboardApiError) {
+ console.error('Original Clipboard API error:', clipboardApiError);
+ }
+
+ throw fallbackError;
+ } finally {
+ document.body.removeChild(textarea);
+ }
+};
+
+const paymentProviders: PaymentProviderSetup[] = [
{
key: 'stripe',
label: 'Stripe',
- categoryLabel: 'Payment trigger',
+ eventLabel: 'checkout.session.completed or payment_intent.succeeded',
defaultReviewDestination: 'google',
- businessTypes: ['local', 'online', 'hybrid'],
- description: 'Connect card and checkout payments from Stripe.',
- },
- {
- key: 'paypal',
- label: 'PayPal',
- categoryLabel: 'Payment trigger',
- defaultReviewDestination: 'google',
- businessTypes: ['local', 'online', 'hybrid'],
- description: 'Connect completed PayPal captures and sales.',
+ providerArea: 'Stripe Dashboard → Developers → Webhooks',
+ instructions: [
+ 'First, open Stripe Dashboard → Developers → Webhooks → Add endpoint.',
+ 'Then copy the ReviewFlow webhook URL from this screen and paste it into Stripe as the endpoint URL.',
+ 'Next, enable checkout.session.completed or payment_intent.succeeded events and save the webhook.',
+ 'Finally, paste the same webhook URL back into the confirmation box below and run the test transaction.',
+ ],
+ testWindow: {
+ title: 'Stripe test transaction window',
+ steps: [
+ 'ReviewFlow will send a safe Stripe-style checkout.session.completed test event to your generated webhook URL.',
+ 'The backend will process it through the same webhook processor used by live payment events.',
+ 'When the test transaction appears below, the status changes to Complete and Finish setup unlocks.',
+ ],
+ success: 'A successful Stripe test payment was received and saved as a ReviewFlow transaction.',
+ },
+ summary: 'Stripe is connected as the payment trigger for completed card or checkout payments.',
},
{
key: 'square',
label: 'Square',
- categoryLabel: 'Payment trigger',
+ eventLabel: 'payment.created or payment.updated',
defaultReviewDestination: 'google',
- businessTypes: ['local', 'hybrid'],
- description: 'Connect Square payment notifications.',
+ providerArea: 'Square Developer Console → Webhooks → Subscriptions',
+ instructions: [
+ 'First, open Square Developer Console → Webhooks → Subscriptions.',
+ 'Then create a subscription and paste the ReviewFlow webhook URL from this screen.',
+ 'Next, enable completed payment events and save the webhook subscription.',
+ 'Finally, paste the same webhook URL back into the confirmation box below and run the test transaction.',
+ ],
+ testWindow: {
+ title: 'Square test transaction window',
+ steps: [
+ 'ReviewFlow will send a safe Square-style payment.created test event to your generated webhook URL.',
+ 'The backend will process it through the same webhook processor used by live Square events.',
+ 'When the test transaction appears below, the status changes to Complete and Finish setup unlocks.',
+ ],
+ success: 'A successful Square test payment was received and saved as a ReviewFlow transaction.',
+ },
+ summary: 'Square is connected as the payment trigger for completed in-person or online payments.',
+ },
+ {
+ key: 'paypal',
+ label: 'PayPal',
+ eventLabel: 'PAYMENT.CAPTURE.COMPLETED or PAYMENT.SALE.COMPLETED',
+ defaultReviewDestination: 'google',
+ providerArea: 'PayPal Developer Dashboard → Apps & Credentials → Webhooks',
+ instructions: [
+ 'First, open PayPal Developer Dashboard → Apps & Credentials → Webhooks.',
+ 'Then add a webhook and paste the ReviewFlow webhook URL from this screen.',
+ 'Next, subscribe to completed payment/capture events and save the webhook.',
+ 'Finally, paste the same webhook URL back into the confirmation box below and run the test transaction.',
+ ],
+ testWindow: {
+ title: 'PayPal test transaction window',
+ steps: [
+ 'ReviewFlow will send a safe PayPal-style PAYMENT.CAPTURE.COMPLETED test event to your generated webhook URL.',
+ 'The backend will process it through the same webhook processor used by live PayPal events.',
+ 'When the test transaction appears below, the status changes to Complete and Finish setup unlocks.',
+ ],
+ success: 'A successful PayPal test payment was received and saved as a ReviewFlow transaction.',
+ },
+ summary: 'PayPal is connected as the payment trigger for completed captures, sales, or orders.',
},
{
key: 'shopify',
label: 'Shopify',
- categoryLabel: 'Ecommerce order trigger + hosted reviews',
+ eventLabel: 'orders/paid',
defaultReviewDestination: 'shopify_hosted',
- businessTypes: ['online', 'hybrid'],
- description:
- 'Connect paid Shopify orders; customers review products on a hosted Review Flow form.',
+ providerArea: 'Shopify admin → Settings → Notifications → Webhooks',
+ instructions: [
+ 'First, open Shopify admin → Settings → Notifications → Webhooks.',
+ 'Then create an orders/paid webhook in JSON format and paste the ReviewFlow webhook URL from this screen.',
+ 'Next, save the webhook so paid orders are sent to ReviewFlow.',
+ 'Finally, paste the same webhook URL back into the confirmation box below and run the test transaction.',
+ ],
+ testWindow: {
+ title: 'Shopify test order window',
+ steps: [
+ 'ReviewFlow will send a safe Shopify-style orders/paid test event to your generated webhook URL.',
+ 'The backend will process it through the same webhook processor used by live Shopify paid orders.',
+ 'When the test order appears below, the status changes to Complete and Finish setup unlocks.',
+ ],
+ success: 'A successful Shopify paid-order test was received and saved as a ReviewFlow transaction.',
+ },
+ summary: 'Shopify is connected as the order trigger for paid ecommerce orders.',
},
{
key: 'woocommerce',
label: 'WooCommerce',
- categoryLabel: 'Ecommerce order trigger',
+ eventLabel: 'order.created or order.updated',
defaultReviewDestination: 'trustpilot',
- businessTypes: ['online', 'hybrid'],
- description: 'Connect WooCommerce orders from your WordPress store.',
+ providerArea: 'WordPress admin → WooCommerce → Settings → Advanced → Webhooks',
+ instructions: [
+ 'First, open WordPress admin → WooCommerce → Settings → Advanced → Webhooks.',
+ 'Then create an active webhook and paste the ReviewFlow webhook URL from this screen.',
+ 'Next, choose order created/updated events so paid order changes are sent to ReviewFlow.',
+ 'Finally, paste the same webhook URL back into the confirmation box below and run the test transaction.',
+ ],
+ testWindow: {
+ title: 'WooCommerce test order window',
+ steps: [
+ 'ReviewFlow will send a safe WooCommerce-style completed order test event to your generated webhook URL.',
+ 'The backend will process it through the same webhook processor used by live WooCommerce orders.',
+ 'When the test order appears below, the status changes to Complete and Finish setup unlocks.',
+ ],
+ success: 'A successful WooCommerce completed-order test was received and saved as a ReviewFlow transaction.',
+ },
+ summary: 'WooCommerce is connected as the order trigger for processing or completed orders.',
},
];
-const reviewDestinationOptions = [
- {
- key: 'google',
- label: 'Google',
- group: 'Local review destinations',
- scope: 'local',
- mode: 'external_link',
- description: 'For local businesses collecting Google profile reviews.',
- },
- {
- key: 'facebook',
- label: 'Facebook',
- group: 'Local review destinations',
- scope: 'local',
- mode: 'external_link',
- description: 'For local Facebook recommendations and reviews.',
- },
- {
- key: 'yelp',
- label: 'Yelp',
- group: 'Local review destinations',
- scope: 'local',
- mode: 'external_link',
- description: 'For local-service Yelp review requests.',
- },
- {
- key: 'angi',
- label: 'Angi',
- group: 'Local review destinations',
- scope: 'local',
- mode: 'external_link',
- description: 'For home-service Angi profile review requests.',
- },
- {
- key: 'opentable',
- label: 'OpenTable',
- group: 'Local review destinations',
- scope: 'local',
- mode: 'external_link',
- description: 'For restaurant guests leaving OpenTable reviews.',
- },
- {
- 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.',
- },
- {
- key: 'trustpilot',
- label: 'Trustpilot',
- group: 'Ecommerce review destinations',
- scope: 'online',
- mode: 'external_link',
- description: 'For ecommerce brand/store review invitations.',
- },
- {
- key: 'custom',
- label: 'Custom review page',
- group: 'Custom destination',
- scope: 'hybrid',
- mode: 'external_link',
- description: 'Use any review page you control.',
- },
-];
+const helpButtonLabel =
+ 'Having problems connecting? Read our FAQs or ask the AI helper.';
-const reviewDestinationGroups = [
- {
- title: 'Local review destinations',
- 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.',
- keys: ['shopify_hosted', 'trustpilot'],
- },
-];
+const reviewDestinationLabels: Record = {
+ google: 'Google',
+ facebook: 'Facebook',
+ yelp: 'Yelp',
+ angi: 'Angi',
+ opentable: 'OpenTable',
+ trustpilot: 'Trustpilot',
+ shopify_hosted: 'Shopify hosted product review',
+ custom: 'Custom review page',
+};
-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.',
- },
-];
+const localReviewDestinations = new Set(['google', 'facebook', 'yelp', 'angi', 'opentable', 'custom']);
+const onlineReviewDestinations = new Set(['trustpilot', 'shopify_hosted', 'custom']);
function normalizeBusinessType(value?: string): BusinessType {
- if (value === 'local' || value === 'online' || value === 'hybrid') {
- return value;
- }
-
- return 'hybrid';
+ return value === 'local' || value === 'online' || value === 'hybrid' ? value : 'hybrid';
}
-function destinationAllowedForBusinessType(
+function normalizeReviewDestination(value?: string) {
+ return value && reviewDestinationLabels[value] ? value : '';
+}
+
+function getSafeReviewDestination(
+ provider: PaymentProviderSetup | undefined,
businessType: BusinessType,
- destination: (typeof reviewDestinationOptions)[number],
+ preferredDestination?: string,
) {
- if (businessType === 'hybrid' || destination.scope === 'hybrid') {
- return true;
+ const preferred = normalizeReviewDestination(preferredDestination);
+ const fallback = provider?.defaultReviewDestination || 'google';
+
+ if (businessType === 'local') {
+ return preferred && localReviewDestinations.has(preferred) ? preferred : 'google';
}
- 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.',
- 'Paste this Review Flow webhook URL as the endpoint URL.',
- 'Send checkout.session.completed, payment_intent.succeeded, and charge.succeeded events.',
- ],
- square: [
- 'Square Developer Dashboard → Webhooks → Create subscription.',
- 'Paste this Review Flow webhook URL as the notification URL.',
- 'Send payment.created and payment.updated events so completed payments queue reviews.',
- ],
- paypal: [
- 'PayPal Developer Dashboard → Webhooks → Add webhook.',
- 'Paste this Review Flow webhook URL as the webhook URL.',
- 'Send PAYMENT.CAPTURE.COMPLETED or PAYMENT.SALE.COMPLETED events.',
- ],
- shopify: [
- 'Shopify admin → Settings → Notifications → Webhooks → Create webhook.',
- 'Choose JSON format, paste this Review Flow webhook URL, and save the webhook.',
- 'Start with orders/paid. Review Flow will generate a hosted product-review page for each paid order.',
- ],
- woocommerce: [
- 'WordPress admin → WooCommerce → Settings → Advanced → Webhooks → Add webhook.',
- 'Set status to Active, paste this Review Flow webhook URL as the delivery URL, and save.',
- 'Create order.created and order.updated webhooks so paid status changes can queue reviews.',
- ],
-};
-
-const providerSetupDetails: Record<
- string,
- {
- dashboardPath: string;
- requiredEvents: string[];
- steps: string[];
- testTip: string;
- }
-> = {
- stripe: {
- dashboardPath:
- 'Stripe Dashboard → Developers → Webhooks → Add endpoint or Create event destination',
- requiredEvents: [
- 'checkout.session.completed',
- 'payment_intent.succeeded',
- 'charge.succeeded',
- ],
- steps: [
- 'Select Stripe in Review Flow, enter the business name, review link, delay days, and click Connect Stripe.',
- 'Copy the generated Review Flow webhook URL from the connected account card.',
- 'In Stripe, create a new webhook endpoint and paste the copied URL into the endpoint URL field.',
- 'Choose your account events unless you are intentionally configuring a Stripe Connect platform flow.',
- 'Add the required successful payment event types listed below, then save the endpoint.',
- 'Send a Stripe test webhook or make a test payment, then return here and click Check webhook status.',
- ],
- testTip:
- 'A successful Stripe delivery should show a 2xx response and create a payment event in Review Flow.',
- },
- square: {
- dashboardPath:
- 'Square Developer Console → Application → Webhooks → Subscriptions → Add subscription',
- requiredEvents: ['payment.created', 'payment.updated'],
- steps: [
- 'Select Square in Review Flow, enter the business name, review link, delay days, and click Connect Square.',
- 'Copy the generated Review Flow webhook URL from the connected account card.',
- 'In Square, open your application, create a webhook subscription, and paste the copied URL as the notification URL.',
- 'Select the payment events listed below so completed payments can be converted into review requests.',
- 'Save the subscription and keep any Square signature details private for future verification hardening.',
- 'Use a Square test payment or webhook test delivery, then return here and click Check webhook status.',
- ],
- testTip:
- 'Square payments queue reviews only when the payment status is completed and a customer email is available.',
- },
- paypal: {
- dashboardPath:
- 'PayPal Developer Dashboard → Apps & Credentials → REST app → Webhooks → Add webhook',
- requiredEvents: [
- 'PAYMENT.CAPTURE.COMPLETED',
- 'PAYMENT.SALE.COMPLETED',
- 'CHECKOUT.ORDER.COMPLETED',
- ],
- steps: [
- 'Select PayPal in Review Flow, enter the business name, review link, delay days, and click Connect PayPal.',
- 'Copy the generated Review Flow webhook URL from the connected account card.',
- 'In PayPal, open the REST app that receives your payments and add a new webhook.',
- 'Paste the copied URL into the webhook URL field and subscribe to the completed payment events listed below.',
- 'Save the webhook, then confirm it is attached to the same PayPal app used by your checkout flow.',
- 'Run a sandbox checkout or webhook simulator event, then return here and click Check webhook status.',
- ],
- testTip:
- 'A completed PayPal capture, sale, or checkout order should appear as a payment event before a review request is queued.',
- },
- shopify: {
- dashboardPath:
- 'Shopify admin → Settings → Notifications → Webhooks → Create webhook',
- requiredEvents: ['orders/paid', 'orders/create', 'orders/fulfilled'],
- steps: [
- 'Select Shopify in Review Flow, enter the business name and delay days, and click Connect Shopify.',
- 'Copy the generated Review Flow webhook URL from the connected account card.',
- 'In Shopify admin, open Settings, then Notifications, then create a webhook in the Webhooks section.',
- 'Choose the Order payment / orders/paid event, select JSON format, paste the copied URL, and save the webhook.',
- 'Review Flow will create a customer, transaction, and hosted product-review form link for each paid order with an email.',
- 'Use Shopify test notifications or place a test paid order, then return here and click Check webhook status.',
- ],
- testTip:
- 'A paid Shopify order queues a hosted Review Flow product review when the payload includes a customer email and financial_status is paid.',
- },
- woocommerce: {
- dashboardPath:
- 'WordPress admin → WooCommerce → Settings → Advanced → Webhooks → Add webhook',
- requiredEvents: ['order.created', 'order.updated'],
- steps: [
- 'Select WooCommerce in Review Flow, enter the business name, review link, delay days, and click Connect WooCommerce.',
- 'Copy the generated Review Flow webhook URL from the connected account card.',
- 'In WordPress admin, open WooCommerce, then Settings, then Advanced, then Webhooks, and click Add webhook.',
- 'Name the webhook Review Flow, set Status to Active, choose the Order created topic, and paste the copied URL into Delivery URL.',
- 'Save the webhook, then add a second Active webhook for Order updated so status changes to processing or completed are captured.',
- 'Create or update a test order to processing/completed, then return here and click Check webhook status.',
- ],
- testTip:
- 'A WooCommerce order queues a review when status is processing or completed and the billing email is present.',
- },
-};
-
-type ProviderApiBackup = {
- summary: string;
- samplePayload: string;
- successTip: string;
-};
-
-type PaymentSetupMethod = 'Webhook' | 'API backup';
-
-type PaymentWebhookEvent = {
- provider?: string;
- provider_event_type?: string;
- event_type?: string;
- processed?: boolean;
- processing_error?: string;
- createdAt?: string;
- business?: { name?: string };
-};
-
-type VerifiedPaymentSetup = {
- provider: string;
- providerLabel: string;
- businessName: string;
- method: PaymentSetupMethod;
- reviewDestinationLabel: string;
- verifiedAt: string;
- statusNote: string;
- eventType?: 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',
- paypal: 'from-sky-600 to-blue-700',
- shopify: 'from-lime-600 to-emerald-700',
- woocommerce: 'from-purple-700 to-fuchsia-700',
-};
-
-function getProviderCardTitle(provider: ProviderConnector) {
- if (provider.key === 'shopify' || provider.hosted_review_provider) {
- return 'Order trigger + hosted review form';
+ if (businessType === 'online') {
+ if (preferred && onlineReviewDestinations.has(preferred)) return preferred;
+ return provider?.key === 'shopify' ? 'shopify_hosted' : 'trustpilot';
}
- return 'Webhook receiver';
+ return preferred || fallback;
}
-function formatDate(value?: string | null) {
- if (!value) return 'Not scheduled';
-
+function formatCompletedAt(date: Date) {
return new Intl.DateTimeFormat('en', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
- }).format(new Date(value));
+ }).format(date);
}
-function hasAuthToken() {
- return (
- typeof window !== 'undefined' && Boolean(localStorage.getItem('token'))
- );
+function getErrorMessage(error: unknown, fallback: string) {
+ if (axios.isAxiosError(error)) {
+ const data = error.response?.data;
+
+ if (typeof data === 'string') return data;
+ if (typeof data?.message === 'string') return data.message;
+ if (typeof data?.error === 'string') return data.error;
+ }
+
+ if (error instanceof Error) return error.message;
+
+ return fallback;
}
-function isUnauthorizedError(error: unknown) {
- return axios.isAxiosError(error) && error.response?.status === 401;
+function findProviderConnector(business: ConnectorBusiness | null, providerKey: string) {
+ return business?.providers?.find((provider) => provider.key === providerKey) || null;
+}
+
+function getWebhookAxiosPath(webhookUrl: string) {
+ try {
+ const parsed = new URL(webhookUrl);
+ const apiIndex = parsed.pathname.indexOf('/api/');
+
+ if (apiIndex >= 0) {
+ return parsed.pathname.slice(apiIndex + 4);
+ }
+
+ return parsed.pathname;
+ } catch {
+ const apiIndex = webhookUrl.indexOf('/api/');
+
+ if (apiIndex >= 0) {
+ return webhookUrl.slice(apiIndex + 4);
+ }
+
+ return webhookUrl;
+ }
+}
+
+function createTestPayload(providerKey: PaymentProviderKey): TestPayload {
+ const now = new Date();
+ const isoNow = now.toISOString();
+ const timestamp = now.getTime();
+ const customer = 'Taylor Test';
+ const email = 'taylor.test@example.com';
+ const amount = '$149.00';
+
+ if (providerKey === 'stripe') {
+ const reference = `cs_test_reviewflow_${timestamp}`;
+
+ return {
+ payload: {
+ id: `evt_reviewflow_test_${timestamp}`,
+ type: 'checkout.session.completed',
+ created: Math.floor(timestamp / 1000),
+ data: {
+ object: {
+ id: reference,
+ object: 'checkout.session',
+ amount_total: 14900,
+ currency: 'usd',
+ customer_email: email,
+ customer_details: { email, name: customer, phone: '+15555550100' },
+ payment_intent: `pi_reviewflow_test_${timestamp}`,
+ description: 'ReviewFlow guided test transaction',
+ created: Math.floor(timestamp / 1000),
+ },
+ },
+ },
+ transaction: {
+ providerReference: reference,
+ customer,
+ email,
+ amount,
+ eventType: 'checkout.session.completed',
+ receivedAt: formatCompletedAt(now),
+ },
+ };
+ }
+
+ if (providerKey === 'square') {
+ const reference = `sq_pay_reviewflow_${timestamp}`;
+
+ return {
+ payload: {
+ event_id: `sq_evt_reviewflow_${timestamp}`,
+ type: 'payment.created',
+ created_at: isoNow,
+ data: {
+ object: {
+ payment: {
+ id: reference,
+ status: 'COMPLETED',
+ total_money: { amount: 14900, currency: 'USD' },
+ buyer_email_address: email,
+ customer: { given_name: 'Taylor', family_name: 'Test', email_address: email, phone_number: '+15555550100' },
+ customer_id: `SQ-CUST-${timestamp}`,
+ note: 'ReviewFlow guided test transaction',
+ created_at: isoNow,
+ updated_at: isoNow,
+ },
+ },
+ },
+ },
+ transaction: {
+ providerReference: reference,
+ customer,
+ email,
+ amount,
+ eventType: 'payment.created',
+ receivedAt: formatCompletedAt(now),
+ },
+ };
+ }
+
+ if (providerKey === 'paypal') {
+ const reference = `CAPTURE-REVIEWFLOW-${timestamp}`;
+
+ return {
+ payload: {
+ id: `WH-REVIEWFLOW-${timestamp}`,
+ event_type: 'PAYMENT.CAPTURE.COMPLETED',
+ create_time: isoNow,
+ resource: {
+ id: reference,
+ amount: { value: '149.00', currency_code: 'USD' },
+ payer: {
+ email_address: email,
+ payer_id: `PAYER-${timestamp}`,
+ name: { given_name: 'Taylor', surname: 'Test' },
+ phone: { phone_number: { national_number: '5555550100' } },
+ },
+ description: 'ReviewFlow guided test transaction',
+ create_time: isoNow,
+ update_time: isoNow,
+ },
+ },
+ transaction: {
+ providerReference: reference,
+ customer,
+ email,
+ amount,
+ eventType: 'PAYMENT.CAPTURE.COMPLETED',
+ receivedAt: formatCompletedAt(now),
+ },
+ };
+ }
+
+ if (providerKey === 'shopify') {
+ const numericId = Number(String(timestamp).slice(-10));
+ const reference = `gid://shopify/Order/${numericId}`;
+
+ return {
+ payload: {
+ id: numericId,
+ admin_graphql_api_id: reference,
+ topic: 'orders/paid',
+ financial_status: 'paid',
+ name: `#RF-${String(timestamp).slice(-5)}`,
+ order_number: numericId,
+ total_price: '149.00',
+ current_total_price: '149.00',
+ currency: 'USD',
+ email,
+ contact_email: email,
+ customer: { id: numericId, first_name: 'Taylor', last_name: 'Test', email, phone: '+15555550100' },
+ billing_address: { first_name: 'Taylor', last_name: 'Test', email, phone: '+15555550100' },
+ line_items: [{ name: 'ReviewFlow guided test order', product_id: numericId, quantity: 1, sku: 'RF-TEST' }],
+ created_at: isoNow,
+ processed_at: isoNow,
+ updated_at: isoNow,
+ webhook_id: `shopify-reviewflow-${timestamp}`,
+ },
+ headers: { 'x-shopify-topic': 'orders/paid', 'x-shopify-webhook-id': `shopify-reviewflow-${timestamp}` },
+ transaction: {
+ providerReference: reference,
+ customer,
+ email,
+ amount,
+ eventType: 'orders/paid',
+ receivedAt: formatCompletedAt(now),
+ },
+ };
+ }
+
+ const reference = `wc_order_reviewflow_${timestamp}`;
+
+ return {
+ payload: {
+ id: timestamp,
+ order_key: reference,
+ number: `RF-${String(timestamp).slice(-5)}`,
+ topic: 'order.updated',
+ status: 'completed',
+ total: '149.00',
+ currency: 'USD',
+ transaction_id: `wc_txn_${timestamp}`,
+ billing: { first_name: 'Taylor', last_name: 'Test', email, phone: '+15555550100' },
+ customer_id: timestamp,
+ date_created: isoNow,
+ date_created_gmt: isoNow,
+ date_paid: isoNow,
+ date_paid_gmt: isoNow,
+ webhook_id: `wc-reviewflow-${timestamp}`,
+ },
+ headers: { 'x-wc-webhook-topic': 'order.updated', 'x-wc-webhook-delivery-id': `wc-reviewflow-${timestamp}` },
+ transaction: {
+ providerReference: reference,
+ customer,
+ email,
+ amount,
+ eventType: 'order.updated',
+ receivedAt: formatCompletedAt(now),
+ },
+ };
}
export default function PaymentProviderConnectors({
className = '',
- 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.',
+ eyebrow = 'Step 2',
+ title = 'Payment system connect',
+ description = 'Choose the payment/order provider that should trigger review requests automatically after completed jobs, payments, or orders.',
+ initialBusinessId = '',
+ initialBusinessName = '',
initialBusinessType = 'hybrid',
+ initialReviewDestination = '',
+ initialReviewLink = '',
+ initialDelayDays = '7',
onConnected,
}: PaymentProviderConnectorsProps) {
- const [connectorForm, setConnectorForm] =
- useState(connectorDefaults);
- const [connectors, setConnectors] = useState([]);
- const [isConnectorLoading, setIsConnectorLoading] = useState(true);
- const [isConnectorSubmitting, setIsConnectorSubmitting] = useState(false);
- const [connectorMessage, setConnectorMessage] = useState('');
+ const [selectedProviderKey, setSelectedProviderKey] = useState('');
+ const [businesses, setBusinesses] = useState([]);
+ const [businessId, setBusinessId] = useState(initialBusinessId);
+ const [businessName, setBusinessName] = useState(initialBusinessName);
+ const [businessType, setBusinessType] = useState(normalizeBusinessType(initialBusinessType));
+ const [reviewDestination, setReviewDestination] = useState(initialReviewDestination || 'google');
+ const [reviewLink, setReviewLink] = useState(initialReviewLink);
+ const [delayDays, setDelayDays] = useState(String(initialDelayDays || '7'));
+ const [accountReference, setAccountReference] = useState('');
+ const [connectedBusiness, setConnectedBusiness] = useState(null);
+ const [reviewFlowWebhookUrl, setReviewFlowWebhookUrl] = useState('');
+ const [confirmedWebhookUrl, setConfirmedWebhookUrl] = useState('');
+ const [testTransactions, setTestTransactions] = useState([]);
+ const [testStatus, setTestStatus] = useState<'idle' | 'running' | 'complete' | 'error'>('idle');
+ const [completedConnection, setCompletedConnection] = useState(null);
+ const [isLoadingConnectors, setIsLoadingConnectors] = useState(false);
+ const [isGeneratingWebhook, setIsGeneratingWebhook] = useState(false);
+ const [isTestingWebhook, setIsTestingWebhook] = useState(false);
+ const [copied, setCopied] = useState(false);
const [error, setError] = useState('');
- const [copiedUrl, setCopiedUrl] = useState('');
- const [isClientReady, setIsClientReady] = useState(false);
- const [subscriptionStatus, setSubscriptionStatus] =
- useState(null);
- const [isApiBackupVisible, setIsApiBackupVisible] = useState(false);
- const [isCheckingWebhook, setIsCheckingWebhook] = useState(false);
- const [paymentSetupFinished, setPaymentSetupFinished] = useState(false);
- const [verifiedPaymentSetup, setVerifiedPaymentSetup] =
- useState(null);
+ const [isStartingOauth, setIsStartingOauth] = useState(false);
+ const [oauthStatusMessage, setOauthStatusMessage] = useState('');
- const currentBusinessType = normalizeBusinessType(connectorForm.businessType);
- const filteredProviderOptions = getProvidersForBusinessType(currentBusinessType);
- const filteredReviewDestinationOptions = getReviewDestinationsForBusinessType(currentBusinessType);
- const selectedProvider =
- filteredProviderOptions.find(
- (provider) => provider.key === connectorForm.provider,
- ) || filteredProviderOptions[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'
- : connectorForm.reviewDestination;
- const selectedReviewDestination =
- filteredReviewDestinationOptions.find(
- (destination) => destination.key === effectiveReviewDestination,
- ) || filteredReviewDestinationOptions[0];
- const isHostedReviewDestination =
- selectedReviewDestination.mode === 'hosted_form';
-
- const connectorPreviewDate = useMemo(() => {
- if (!isClientReady) return 'after the selected delay';
-
- const days = Math.max(0, Number(connectorForm.delayDays) || 0);
- return formatDate(
- new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString(),
- );
- }, [connectorForm.delayDays, isClientReady]);
-
- const providerSummary = useMemo(() => {
- const providers = connectors.flatMap(
- (business) => business.providers || [],
- );
- const connectedCount = providers.filter(
- (provider) => provider.connected,
- ).length;
-
- return {
- connectedCount,
- totalCount: providers.length || filteredProviderOptions.length,
- };
- }, [connectors, filteredProviderOptions.length]);
-
- 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 selectedProvider = useMemo(
+ () => paymentProviders.find((provider) => provider.key === selectedProviderKey),
+ [selectedProviderKey],
);
- const updateConnectorForm = (
- key: keyof ConnectorFormValues,
- value: string,
- ) => {
- if (key === 'businessType') {
- setIsApiBackupVisible(false);
- setPaymentSetupFinished(false);
- }
+ const selectedBusiness = useMemo(() => {
+ if (connectedBusiness) return connectedBusiness;
- 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 businesses.find((business) => business.id === businessId) || businesses[0] || null;
+ }, [businessId, businesses, connectedBusiness]);
- return {
- ...current,
- businessType,
- provider: provider.key,
- reviewDestination: getAllowedReviewDestination(
- businessType,
- provider.defaultReviewDestination === 'shopify_hosted'
- ? 'shopify_hosted'
- : current.reviewDestination,
- ),
- };
- }
+ const providerConnector = useMemo(
+ () => findProviderConnector(selectedBusiness, selectedProviderKey),
+ [selectedBusiness, selectedProviderKey],
+ );
- return { ...current, [key]: value };
- });
- };
-
- const updateSelectedProvider = (providerKey: string) => {
- setIsApiBackupVisible(false);
- setPaymentSetupFinished(false);
-
- const provider =
- filteredProviderOptions.find(
- (providerOption) => providerOption.key === providerKey,
- ) || filteredProviderOptions[0];
-
- setConnectorForm((current) => ({
- ...current,
- provider: provider.key,
- reviewDestination: getAllowedReviewDestination(
- currentBusinessType,
- provider.defaultReviewDestination === 'shopify_hosted'
- ? 'shopify_hosted'
- : current.reviewDestination,
- ),
- }));
- };
-
- const loadConnectors = async () => {
- setIsConnectorLoading(true);
- try {
- const response = await axios.get('/reviewflow/connectors');
- setConnectors(response.data.businesses || []);
- setError('');
- } catch (requestError) {
- if (!isUnauthorizedError(requestError)) {
- console.error(
- 'Failed to load payment webhook connectors:',
- requestError,
- );
- setError(
- 'Could not load your payment connectors. Please refresh or try again.',
- );
- }
- } finally {
- setIsConnectorLoading(false);
- }
- };
-
- const loadSubscriptionStatus = async () => {
- try {
- const response = await axios.get('/subscription/me');
- setSubscriptionStatus(response.data);
- } catch (requestError) {
- if (!isUnauthorizedError(requestError)) {
- console.error('Failed to load connector subscription status:', requestError);
- }
- }
- };
+ const isWebhookConfirmed = Boolean(
+ reviewFlowWebhookUrl && confirmedWebhookUrl.trim() === reviewFlowWebhookUrl.trim(),
+ );
+ const canRunTest = Boolean(selectedProvider && reviewFlowWebhookUrl && isWebhookConfirmed && !isTestingWebhook);
+ const canFinish = testStatus === 'complete' && testTransactions.length > 0;
useEffect(() => {
- setIsClientReady(true);
-
- if (!hasAuthToken()) {
- setIsConnectorLoading(false);
- return;
- }
-
- loadConnectors();
- loadSubscriptionStatus();
- }, []);
-
- useEffect(() => {
- updateConnectorForm('businessType', normalizeBusinessType(initialBusinessType));
+ setBusinessType(normalizeBusinessType(initialBusinessType));
}, [initialBusinessType]);
- const handleConnectorSubmit = async (event: FormEvent) => {
- event.preventDefault();
- setIsConnectorSubmitting(true);
- setConnectorMessage('');
- setError('');
+ useEffect(() => {
+ if (initialBusinessId) setBusinessId(initialBusinessId);
+ }, [initialBusinessId]);
- try {
- const submittedConnectorForm = {
- ...connectorForm,
- reviewDestination: effectiveReviewDestination,
- reviewLink: isHostedReviewDestination ? '' : connectorForm.reviewLink,
- };
- const response = await axios.post('/reviewflow/connectors', {
- ...submittedConnectorForm,
- delayDays: Number(connectorForm.delayDays),
- });
- const business = response.data.business as ConnectorBusiness;
- setConnectorMessage(
- `${selectedProvider.label} is connected for ${business.name}. ${selectedReviewDestination.label} is the review destination. Copy the secure webhook URL below into your ${selectedProvider.label} dashboard.`,
- );
- setIsApiBackupVisible(false);
- setPaymentSetupFinished(false);
- setVerifiedPaymentSetup(null);
+ useEffect(() => {
+ if (initialBusinessName) setBusinessName(initialBusinessName);
+ }, [initialBusinessName]);
- await Promise.all([loadConnectors(), loadSubscriptionStatus()]);
+ useEffect(() => {
+ if (initialReviewLink) setReviewLink(initialReviewLink);
+ }, [initialReviewLink]);
- if (onConnected) {
- try {
- await onConnected(business, submittedConnectorForm);
- } catch (refreshError) {
- console.error(
- 'Payment connector post-connect refresh failed:',
- refreshError,
- );
- setError(
- 'The connection was created, but the dashboard refresh failed. Please refresh the page to see the latest data.',
- );
+ useEffect(() => {
+ if (initialDelayDays !== undefined && initialDelayDays !== null) {
+ setDelayDays(String(initialDelayDays));
+ }
+ }, [initialDelayDays]);
+
+ useEffect(() => {
+ setReviewDestination(getSafeReviewDestination(selectedProvider, businessType, initialReviewDestination || reviewDestination));
+ }, [businessType, initialReviewDestination, selectedProvider, reviewDestination]);
+
+ useEffect(() => {
+ let isMounted = true;
+
+ const loadConnectorBusinesses = async () => {
+ setIsLoadingConnectors(true);
+
+ try {
+ const response = await axios.get('/reviewflow/connectors');
+ const loadedBusinesses: ConnectorBusiness[] = Array.isArray(response.data?.businesses)
+ ? response.data.businesses
+ : [];
+
+ if (!isMounted) return;
+
+ setBusinesses(loadedBusinesses);
+
+ const preferredBusiness = loadedBusinesses.find((business) => business.id === initialBusinessId) || loadedBusinesses[0];
+
+ if (preferredBusiness) {
+ setBusinessId((current) => current || preferredBusiness.id);
+ setBusinessName((current) => current || preferredBusiness.name || '');
+ setBusinessType((current) => normalizeBusinessType(current || preferredBusiness.business_type));
+ setDelayDays((current) => current || String(preferredBusiness.delay_days ?? 7));
}
+ } catch (requestError) {
+ console.error('Failed to load ReviewFlow connector businesses:', requestError);
+ if (isMounted) {
+ setError(getErrorMessage(requestError, 'Could not load existing payment connector details.'));
+ }
+ } finally {
+ if (isMounted) setIsLoadingConnectors(false);
}
- } catch (requestError) {
- console.error(
- 'Failed to connect payment webhook provider:',
- requestError,
- );
- if (axios.isAxiosError(requestError) && requestError.response?.data) {
- setError(String(requestError.response.data));
- } else {
- setError(
- 'Could not connect this payment provider. Please check the fields and try again.',
- );
- }
- } finally {
- setIsConnectorSubmitting(false);
- }
- };
+ };
- const rotateWebhookToken = async (businessId: string, provider: string) => {
- setConnectorMessage('');
+ loadConnectorBusinesses();
+
+ return () => {
+ isMounted = false;
+ };
+ }, [initialBusinessId]);
+
+ useEffect(() => {
+ if (!selectedProvider) return;
+
+ setReviewFlowWebhookUrl(providerConnector?.webhook_url || '');
+ setConnectedBusiness(providerConnector?.webhook_url ? selectedBusiness : null);
+ setConfirmedWebhookUrl('');
+ setTestTransactions([]);
+ setTestStatus('idle');
+ setCompletedConnection(null);
+ setCopied(false);
setError('');
+ setIsStartingOauth(false);
+ setOauthStatusMessage('');
+ setReviewDestination((current) => getSafeReviewDestination(selectedProvider, businessType, current || initialReviewDestination));
+ setAccountReference(providerConnector?.account_reference || '');
+ }, [businessType, initialReviewDestination, providerConnector, selectedBusiness, selectedProvider]);
- try {
- await axios.post(
- `/reviewflow/connectors/${businessId}/${provider}/rotate`,
- );
- setConnectorMessage(
- `${provider.toUpperCase()} webhook token rotated. Update the webhook URL inside ${provider.toUpperCase()} before sending more live payments.`,
- );
- await loadConnectors();
- } catch (requestError) {
- console.error('Failed to rotate payment webhook token:', requestError);
- setError('Could not rotate the webhook token. Please try again.');
- }
+ const handleProviderChange = (event: React.ChangeEvent) => {
+ const nextProviderKey = event.target.value as PaymentProviderKey | '';
+ setSelectedProviderKey(nextProviderKey);
};
- const copyWebhookUrl = async (url?: string) => {
- if (!url) return;
+ const createWebhookUrl = async () => {
+ if (!selectedProvider) return;
- try {
- await navigator.clipboard.writeText(url);
- setCopiedUrl(url);
- setConnectorMessage(
- 'Webhook URL copied. Paste it into the matching payment provider dashboard.',
- );
- window.setTimeout(() => setCopiedUrl(''), 2500);
- } catch (requestError) {
- console.error('Failed to copy webhook URL:', requestError);
- setError(
- 'Could not copy the webhook URL. You can still select and copy it manually.',
- );
- }
- };
+ const safeBusinessName = businessName.trim();
- const finishPaymentSetup = (
- method: PaymentSetupMethod = 'Webhook',
- statusNote = 'Manual verification saved after a successful provider test.',
- eventType?: string,
- ) => {
- const target = selectedWebhookTargets[0];
-
- if (!target) {
- setError(
- `Connect ${selectedProvider.label} first so Review Flow can generate the secure webhook URL before verification.`,
- );
+ if (!safeBusinessName) {
+ setError('Add your business name first, then create the webhook URL.');
return;
}
- setVerifiedPaymentSetup({
- provider: selectedProvider.key,
- providerLabel: selectedProvider.label,
- businessName: target.businessName || connectorForm.businessName || 'Connected business',
- method,
- reviewDestinationLabel: selectedReviewDestination.label,
- verifiedAt: new Date().toISOString(),
- statusNote,
- eventType,
- });
- setPaymentSetupFinished(true);
+ setIsGeneratingWebhook(true);
setError('');
- setConnectorMessage(
- `${selectedProvider.label} ${method.toLowerCase()} setup marked verified. You can edit this setup any time.`,
+ setOauthStatusMessage('');
+
+ try {
+ const response = await axios.post('/reviewflow/connectors', {
+ businessId: businessId || undefined,
+ provider: selectedProvider.key,
+ businessName: safeBusinessName,
+ businessType,
+ reviewDestination: getSafeReviewDestination(selectedProvider, businessType, reviewDestination),
+ reviewLink: reviewLink.trim(),
+ delayDays,
+ accountReference: accountReference.trim(),
+ });
+ const business = response.data?.business as ConnectorBusiness;
+ const connector = findProviderConnector(business, selectedProvider.key);
+
+ if (!business || !connector?.webhook_url) {
+ throw new Error('The backend did not return a webhook URL for this provider.');
+ }
+
+ setConnectedBusiness(business);
+ setBusinessId(business.id);
+ setBusinessName(business.name || safeBusinessName);
+ setReviewFlowWebhookUrl(connector.webhook_url);
+ setConfirmedWebhookUrl('');
+ setTestTransactions([]);
+ setTestStatus('idle');
+ setCopied(false);
+ } catch (requestError) {
+ console.error('Failed to create ReviewFlow webhook URL:', requestError);
+ setError(getErrorMessage(requestError, 'Could not create the webhook URL.'));
+ throw requestError;
+ } finally {
+ setIsGeneratingWebhook(false);
+ }
+ };
+
+ const copyWebhookUrl = async () => {
+ if (!reviewFlowWebhookUrl) return;
+
+ try {
+ await copyTextToClipboard(reviewFlowWebhookUrl);
+ setCopied(true);
+ setError('');
+ } catch (copyError) {
+ console.error('Failed to copy webhook URL:', copyError);
+ setCopied(false);
+ setError('Could not copy the webhook URL. Select the URL text and copy it manually.');
+ }
+ };
+
+ const runTestTransaction = async () => {
+ if (!selectedProvider || !reviewFlowWebhookUrl) return;
+
+ if (!isWebhookConfirmed) {
+ setError('Paste the exact ReviewFlow webhook URL into the confirmation box before running the test.');
+ return;
+ }
+
+ setIsTestingWebhook(true);
+ setTestStatus('running');
+ setError('');
+ setOauthStatusMessage('');
+
+ const testPayload = createTestPayload(selectedProvider.key);
+ const webhookPath = getWebhookAxiosPath(reviewFlowWebhookUrl);
+
+ try {
+ const response = await axios.post(webhookPath, testPayload.payload, {
+ headers: testPayload.headers,
+ });
+ const savedTransactionId = response.data?.transactionId || testPayload.transaction.providerReference;
+ const message = response.data?.message || selectedProvider.testWindow.success;
+
+ setTestTransactions([
+ {
+ id: savedTransactionId,
+ message,
+ ...testPayload.transaction,
+ },
+ ]);
+ setTestStatus('complete');
+ } catch (requestError) {
+ console.error('Failed to run ReviewFlow test transaction:', {
+ provider: selectedProvider.key,
+ webhookPath,
+ error: requestError,
+ });
+ setTestStatus('error');
+ setError(getErrorMessage(requestError, 'The test transaction did not reach ReviewFlow. Check the webhook URL and try again.'));
+ throw requestError;
+ } finally {
+ setIsTestingWebhook(false);
+ }
+ };
+
+
+ const startOauthBackup = async (reason: OauthBackupReason) => {
+ if (!selectedProvider) return;
+
+ setIsStartingOauth(true);
+ setError('');
+ setOauthStatusMessage('');
+
+ try {
+ const response = await axios.post(`/reviewflow/oauth/${selectedProvider.key}/start`, {
+ businessId: connectedBusiness?.id || businessId || undefined,
+ accountReference: accountReference.trim(),
+ reason,
+ primaryConnection: 'webhook',
+ });
+ const authorizationUrl = response.data?.authorizationUrl;
+
+ if (!authorizationUrl) {
+ throw new Error('The backend did not return a payment provider login URL.');
+ }
+
+ setOauthStatusMessage(response.data?.message || `Opening ${selectedProvider.label} provider login…`);
+ window.location.assign(authorizationUrl);
+ } catch (requestError) {
+ console.error('Failed to start ReviewFlow OAuth backup:', {
+ provider: selectedProvider.key,
+ reason,
+ error: requestError,
+ });
+ const message = getErrorMessage(
+ requestError,
+ 'Could not start provider OAuth backup. Webhook remains the primary connection.',
+ );
+ setOauthStatusMessage(message);
+ setError(message);
+ throw requestError;
+ } finally {
+ setIsStartingOauth(false);
+ }
+ };
+
+ const finishSetup = async () => {
+ if (!selectedProvider || !connectedBusiness || !canFinish) return;
+
+ const completedAt = new Date();
+ const connectorForm: ConnectorFormValues = {
+ provider: selectedProvider.key,
+ businessType,
+ businessName: businessName.trim(),
+ reviewDestination: getSafeReviewDestination(selectedProvider, businessType, reviewDestination),
+ reviewLink: reviewLink.trim(),
+ delayDays,
+ accountReference: accountReference.trim(),
+ };
+
+ try {
+ if (onConnected) {
+ await onConnected(connectedBusiness, connectorForm);
+ }
+
+ setCompletedConnection({
+ providerKey: selectedProvider.key,
+ providerLabel: selectedProvider.label,
+ status: 'Complete',
+ completedAt: formatCompletedAt(completedAt),
+ summary: selectedProvider.summary,
+ testResult: testTransactions[0]?.message || selectedProvider.testWindow.success,
+ webhookUrl: reviewFlowWebhookUrl,
+ });
+ } catch (requestError) {
+ console.error('Failed to finish payment provider setup:', requestError);
+ setError(getErrorMessage(requestError, 'The provider was tested, but the setup summary could not be refreshed.'));
+ throw requestError;
+ }
+ };
+
+
+ const renderOauthBackupCard = (reason: OauthBackupReason) => {
+ if (!selectedProvider) return null;
+
+ const isFailedTest = reason === 'failed_test';
+
+ return (
+
+
+
+
B
+
+
Backup option
+
Connect via Your Payment Provider
+
+
+
Webhook stays primary
+
+
+ {isFailedTest
+ ? 'The webhook test failed, so OAuth is available as a backup path for account verification and recovery.'
+ : 'Webhook setup is complete. OAuth is optional backup only, useful for account verification, recovery, or future manual sync.'}
+ {' '}ReviewFlow will still rely on webhook events as the main payment trigger instead of constantly calling the provider API.
+
+ {(selectedProvider.key === 'shopify' || selectedProvider.key === 'woocommerce') ? (
+
+ For {selectedProvider.label}, enter your store domain in the account/store label field before using OAuth backup.
+
+ ) : null}
+
+ startOauthBackup(reason)}
+ />
+
+ Backup only — not required for normal webhook automation.
+
+
+ {oauthStatusMessage ? (
+
+ {oauthStatusMessage}
+
+ ) : null}
+
);
};
- const checkWebhookStatus = async () => {
- const target = selectedWebhookTargets[0];
-
- if (!target) {
- setError(
- `Connect ${selectedProvider.label} first, then paste its webhook URL into ${selectedProvider.label} and send a test event.`,
- );
- return;
- }
-
- setIsCheckingWebhook(true);
- setError('');
- setConnectorMessage('');
-
- try {
- const response = await axios.get('/reviewflow/summary?limit=12');
- const events: PaymentWebhookEvent[] = Array.isArray(response.data?.recentEvents)
- ? response.data.recentEvents
- : [];
- const matchingEvent = events.find((event) => {
- const providerMatches =
- (event.provider || '').toLowerCase() === selectedProvider.key;
- const businessMatches =
- !event.business?.name || event.business.name === target.businessName;
-
- return providerMatches && businessMatches;
- });
-
- await loadConnectors();
-
- if (matchingEvent) {
- finishPaymentSetup(
- 'Webhook',
- matchingEvent.processing_error
- ? `A ${selectedProvider.label} webhook event arrived, but Review Flow reported: ${matchingEvent.processing_error}`
- : `A ${selectedProvider.label} webhook test event arrived in Review Flow.`,
- matchingEvent.provider_event_type || matchingEvent.event_type,
- );
- return;
- }
-
- setConnectorMessage(
- `No recent ${selectedProvider.label} webhook test event was found yet. Send a provider test event, then click Check webhook status again. If the provider dashboard already shows a successful delivery, you can use the manual verification button.`,
- );
- } catch (requestError) {
- console.error('Failed to check payment webhook status:', requestError);
- setError(
- 'Could not check webhook status right now. If your provider dashboard confirms a successful test delivery, you can still mark it verified manually.',
- );
- } finally {
- setIsCheckingWebhook(false);
- }
- };
-
- const connectorUsage = subscriptionStatus?.usage.paymentConnectors ?? 0;
- const connectorLimit = subscriptionStatus?.limits.paymentConnectors ?? 0;
- const businessUsage = subscriptionStatus?.usage.businesses ?? 0;
- const businessLimit = subscriptionStatus?.limits.businesses ?? 0;
- const isConnectorSubscriptionInactive = Boolean(
- subscriptionStatus && !subscriptionStatus.subscription.isActive,
- );
- const isConnectorLimitReached = Boolean(
- subscriptionStatus && connectorLimit > 0 && connectorUsage >= connectorLimit,
- );
- const isBusinessLimitReached = Boolean(
- subscriptionStatus && businessLimit > 0 && businessUsage >= businessLimit,
- );
- const shouldShowConnectorLimitCta =
- isConnectorSubscriptionInactive || isConnectorLimitReached || isBusinessLimitReached;
- const connectorLimitButtonLabel =
- subscriptionStatus?.subscription.planId === 'starter' ? 'Upgrade to Pro' : 'Manage plan';
-
return (
-
-
-
-
- {eyebrow}
-
-
- {title}
-
-
- {description}
-
-
-
-
-
- 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.
-
-
-
- {connectorMessage && (
-
- Connection update. {connectorMessage}
-
- )}
- {error && (
-
-
{error}
- {error.includes('Upgrade to Pro') && (
-
- )}
-
- )}
-
- {shouldShowConnectorLimitCta && (
-
-
- {isConnectorSubscriptionInactive ? 'Subscription inactive' : 'Plan limit may block new connections'}
-
-
- {isConnectorSubscriptionInactive
- ? 'Provider connections are paused until this account has an active plan.'
- : `${subscriptionStatus?.subscription.planName} currently uses ${connectorUsage.toLocaleString()} / ${connectorLimit.toLocaleString()} provider connectors and ${getBusinessProfileUsageLabel(businessUsage, businessLimit)}.`}
- {' '}Updating an already connected provider may still work, but new providers or new business profiles can be blocked.
-
-
-
- )}
-
-
- {reviewDestinationGroups.map((group) => (
-
-
- Review destination group
-
-
- {group.title}
-
-
- {group.subtitle}
-
-
- {group.keys.map((destinationKey) => {
- const destination = reviewDestinationOptions.find(
- (option) => option.key === destinationKey,
- );
-
- if (!destination) return null;
-
- return (
-
- {destination.label}
-
- );
- })}
-
-
- ))}
-
-
- {paymentSetupFinished && verifiedPaymentSetup ? (
-
+
+ {completedConnection ? (
+
-
- Payment setup finished
-
-
- {verifiedPaymentSetup.providerLabel} is ready to trigger review requests
-
-
- Review Flow will use this payment setup for {verifiedPaymentSetup.businessName}.
+
+ Connection status
+
+ {completedConnection.status}
+
-
{
- setPaymentSetupFinished(false);
- setConnectorMessage('');
- }}
- />
+
+ {completedConnection.providerLabel}
+
-
-
-
Provider
-
{verifiedPaymentSetup.providerLabel}
+
+
+
Summary
+
{completedConnection.summary}
+
Webhook: {completedConnection.webhookUrl}
-
-
Method
-
{verifiedPaymentSetup.method}
-
-
-
Review destination
-
{verifiedPaymentSetup.reviewDestinationLabel}
-
-
-
Verified
-
{formatDate(verifiedPaymentSetup.verifiedAt)}
+
+
Test result
+
{completedConnection.testResult}
+
+ Finished {completedConnection.completedAt}
+
-
-
Status: {verifiedPaymentSetup.statusNote}
- {verifiedPaymentSetup.eventType && (
-
Last event: {verifiedPaymentSetup.eventType}
- )}
+
+ {renderOauthBackupCard('completed_webhook')}
) : (
<>
-
-
- Cleaner setup flow
-
-
- Choose one provider, then follow one guide
-
-
- The provider dropdown below controls the instructions. Review Flow
- shows the detailed webhook setup for only the selected payment or
- ecommerce company. API backup instructions stay hidden unless you click
- Use API instead.
-
-
-
-
- 1. Select provider
-
-
- Pick the relevant provider from the filtered dropdown; Local, Online, and Hybrid setups show different choices.
-
-
-
-
- 2. Connect webhook
-
-
- Connect first so Review Flow generates the secure URL for that
- provider.
-
-
-
-
- 3. Test and finish
-
-
- Send a provider test event, check status here, then finish to see
- the summary box.
-
-
-
-
-
-
-
-
-
-
+
-
- Selected provider guide
-
-
- {selectedProvider.label} setup
-
-
- Follow the webhook instructions first. Click Use API instead
- only when a custom system or automation tool needs the fallback
- POST option.
+
{eyebrow}
+
{title}
+
+ {description}
-
- Webhook setup — recommended
-
-
-
-
-
-
- Dashboard path
-
-
- {selectedSetup.dashboardPath}
-
-
-
-
-
- Install steps
-
-
- {selectedSetup.steps.map((step, index) => (
- -
- {step}
-
- ))}
-
-
-
-
-
- Events to enable
-
-
- {selectedSetup.requiredEvents.map((eventName) => (
-
- {eventName}
-
- ))}
-
-
-
-
- Test after saving: {selectedSetup.testTip}
-
-
-
-
- finishPaymentSetup('Webhook')}
- />
- setIsApiBackupVisible((current) => !current)}
- />
-
-
- Recommended path: use the webhook. Use API only for custom systems or automation tools when the provider dashboard webhook is not practical.
+
+
+
+ Need help during setup? Use the floating AI helper in the bottom-right corner. It knows this setup flow and can walk you through each step.
-
-
-
- API backup
-
-
- Backup POST option for {selectedProvider.label}
-
-
- {selectedApiBackup.summary}
-
+ {error ? (
+
+ ) : null}
-
-
- 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}
-
-
-
- finishPaymentSetup(
- 'API backup',
- 'Manual verification saved after a successful API backup test POST.',
- )
- }
- />
-
-
-
-
- >
- )}
-
-
-
-
- Connected accounts
-
-
- {`${providerSummary.connectedCount} of ${providerSummary.totalCount} provider slots connected`}
-
-
-
-
- {isConnectorLoading ? (
-
- Loading webhook connectors...
-
- ) : connectors.length === 0 ? (
-
-
-
- No order/payment triggers connected yet
-
-
- Choose Stripe, PayPal, Square, Shopify, or WooCommerce above to
- generate your first secure order/payment webhook URL.
-
-
- ) : (
-
- {connectors.map((business) => (
-
-
+
+
+
+
1
-
- {business.name}
-
-
- Default review delay: {business.delay_days ?? 0} days ·
- Review destination:{' '}
- {business.review_destination || 'google'}
-
+
First
+
Select your payment provider
-
-
- {business.providers.map((provider) => (
-
-
-
-
-
- {provider.label}
-
-
- {getProviderCardTitle(provider)}
-
-
-
- {provider.connected ? 'Connected' : 'Not connected'}
+
+
+
+
+ Choose the system where completed payments or paid orders happen. After this, the next steps appear in order.
+
+
+
+ {selectedProvider ? (
+ <>
+
+
+
+
2
+
+
Then
+
Create your ReviewFlow webhook URL
+
+
+
+ Event: {selectedProvider.eventLabel}
+
+
+
+
+ setBusinessName(event.target.value)}
+ placeholder='Business name, e.g. Acme Logistics'
+ />
+ setAccountReference(event.target.value)}
+ placeholder={`${selectedProvider.label} account/store label (optional)`}
+ />
+
+
+
+
+
Business type
+
{businessType}
+
+
+
Review destination used
+
{reviewDestinationLabels[getSafeReviewDestination(selectedProvider, businessType, reviewDestination)]}
+
+
+
Request delay
+
{delayDays || '0'} day(s)
+
+
+
+
+
+ {isLoadingConnectors ? (
+ Loading existing connections…
+ ) : null}
+
+
+ {reviewFlowWebhookUrl ? (
+
+
+
+
Your ReviewFlow webhook URL
+
+
+
+
+
+ Copy this URL now. You will paste it into {selectedProvider.providerArea}.
-
+ ) : null}
+
+
+ {testStatus === 'error' ? renderOauthBackupCard('failed_test') : null}
+
+ {reviewFlowWebhookUrl ? (
+
+
+
3
-
- Webhook URL
-
-
- {provider.webhook_url ||
- 'Connect this provider to reveal its secure webhook endpoint.'}
-
-
-
- copyWebhookUrl(provider.webhook_url)}
- />
-
- rotateWebhookToken(business.id, provider.key)
- }
- />
-
-
-
- Quick setup reminder
-
-
- {(providerInstructions[provider.key] || []).map(
- (instruction) => (
- - {instruction}
- ),
- )}
-
-
- Need API backup? Use the selected provider guide above
- and click Use API instead.
-
- {provider.webhook_token_last4 && (
-
- Secret token ends in: ****
- {provider.webhook_token_last4}
-
- )}
+
Next
+
Paste the webhook into {selectedProvider.label}
+
+
+ {selectedProvider.instructions.map((instruction, index) => (
+ -
+
+ {index + 1}
+
+ {instruction}
+
+ ))}
+
+
+
+
+
+ {confirmedWebhookUrl ? (
+
+ {isWebhookConfirmed
+ ? 'Webhook URL confirmed. You can run the test transaction now.'
+ : 'The pasted URL does not match yet. Copy the ReviewFlow webhook URL exactly, then paste it here.'}
+
+ ) : null}
- ))}
-
-
- ))}
-
+ ) : null}
+
+ {reviewFlowWebhookUrl ? (
+
+
+
+
4
+
+
Then test
+
+ {selectedProvider.testWindow.title}
+
+
+
+
+ {testStatus === 'complete' ? 'Complete' : testStatus === 'running' ? 'Testing…' : testStatus === 'error' ? 'Test failed' : 'Waiting for test'}
+
+
+
+
+ {selectedProvider.testWindow.steps.map((step) => (
+ -
+
+ {step}
+
+ ))}
+
+
+
+
+
+
+
+ {testTransactions.length > 0 ? (
+
+
+
+
+
Test transaction received
+
The webhook processor saved this transaction, so setup can be completed.
+
+
+
+ {testTransactions.map((transaction) => (
+
+
+
Customer: {transaction.customer}
+
Email: {transaction.email}
+
Amount: {transaction.amount}
+
Event: {transaction.eventType}
+
Received: {transaction.receivedAt}
+
Transaction ID: {transaction.id}
+
+ ))}
+
+
+ ) : null}
+
+ ) : null}
+
+ {testStatus === 'error' ? renderOauthBackupCard('failed_test') : null}
+
+ {reviewFlowWebhookUrl ? (
+
+
+
5
+
+
Last
+
Finish and close to summary
+
+
+
+ Finish setup unlocks only after the test transaction appears and the status says Complete.
+
+
+
+ ) : null}
+ >
+ ) : null}
+
+ >
)}
);
diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx
index 3626286..431e86b 100644
--- a/frontend/src/layouts/Authenticated.tsx
+++ b/frontend/src/layouts/Authenticated.tsx
@@ -8,6 +8,7 @@ import NavBar from '../components/NavBar'
import NavBarItemPlain from '../components/NavBarItemPlain'
import AsideMenu from '../components/AsideMenu'
import FooterBar from '../components/FooterBar'
+import FloatingAiHelper from '../components/FloatingAiHelper'
import { useAppDispatch, useAppSelector } from '../stores/hooks'
import Search from '../components/Search';
import { useRouter } from 'next/router'
@@ -153,6 +154,7 @@ export default function LayoutAuthenticated({
/>
{children}
{portalLabel} · ReviewFlow
+
)
diff --git a/frontend/src/pages/businesses/businesses-list.tsx b/frontend/src/pages/businesses/businesses-list.tsx
index 6bdb3c1..653f310 100644
--- a/frontend/src/pages/businesses/businesses-list.tsx
+++ b/frontend/src/pages/businesses/businesses-list.tsx
@@ -115,7 +115,7 @@ const BusinessesTablesPage = () => {
/>
- {hasCreatePermission && }
+ {hasCreatePermission && }
{
/>
- {hasCreatePermission && }
+ {hasCreatePermission && }
= [
@@ -139,6 +153,7 @@ type ReviewDestinationKey = ReviewDestinationOption['key'];
type ReviewDestinationField = Exclude;
const defaultSettings = {
+ ownerId: '',
businessName: 'Review Flow Business',
businessType: 'hybrid' as BusinessType,
reviewDestination: 'google',
@@ -181,6 +196,11 @@ const defaultSettings = {
angiReviewLink: '',
opentableReviewLink: '',
customReviewLink: '',
+ isActive: true,
+ stripeAccountReference: '',
+ stripeConnected: false,
+ stripeConnectedAt: '',
+ defaultReviewPlatform: 'google',
};
type SetupSettings = typeof defaultSettings;
@@ -280,15 +300,59 @@ function formatReviewDestinationList(destinations: ReviewDestinationOption[]) {
return destinations.map((destination) => destination.label).join(', ');
}
+function getBlankBusinessSettings(ownerId = ''): SetupSettings {
+ return {
+ ...defaultSettings,
+ ownerId,
+ businessName: '',
+ delayDays: '',
+ emailSubjectTemplate: '',
+ emailBodyTemplate: '',
+ isActive: false,
+ stripeAccountReference: '',
+ stripeConnected: false,
+ stripeConnectedAt: '',
+ defaultReviewPlatform: 'google',
+ };
+}
+
+function toDateTimeLocalValue(value?: string | null) {
+ if (!value) return '';
+
+ const date = new Date(value);
+
+ if (Number.isNaN(date.getTime())) return '';
+
+ const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
+
+ return localDate.toISOString().slice(0, 16);
+}
+
+function getBusinessTypeLabel(businessType: BusinessType) {
+ return businessTypeOptions.find((option) => option.key === businessType)?.label || 'Hybrid business';
+}
+
+function getDefaultReviewPlatformLabel(value?: string) {
+ const labels: Record = {
+ google: 'google',
+ yelp: 'yelp',
+ facebook: 'facebook',
+ custom: 'custom',
+ };
+
+ return labels[value || 'google'] || labels.google;
+}
+
function businessToSettings(business?: ReviewBusiness | null): SetupSettings {
if (!business) return defaultSettings;
const businessType = normalizeBusinessType(business.business_type);
return {
- businessName: business.name || defaultSettings.businessName,
+ ownerId: business.ownerId || defaultSettings.ownerId,
+ businessName: business.name || '',
businessType,
- reviewDestination: coerceDestination(businessType, business.review_destination),
+ reviewDestination: coerceDestination(businessType, business.review_destination || business.default_review_platform),
delayDays: String(business.delay_days ?? 7),
followupEnabled: business.followup_enabled !== false,
followupDelayDays: String(business.followup_delay_days ?? 3),
@@ -319,6 +383,11 @@ function businessToSettings(business?: ReviewBusiness | null): SetupSettings {
angiReviewLink: business.angi_review_link || '',
opentableReviewLink: business.opentable_review_link || '',
customReviewLink: business.custom_review_link || '',
+ isActive: business.is_active !== false,
+ stripeAccountReference: business.stripe_account_reference || '',
+ stripeConnected: Boolean(business.stripe_connected),
+ stripeConnectedAt: toDateTimeLocalValue(business.stripe_connected_at),
+ defaultReviewPlatform: business.default_review_platform || 'google',
};
}
@@ -376,6 +445,8 @@ function setupStepClass(isComplete: boolean) {
}
export default function SetupPage() {
+ const router = useRouter();
+ const { currentUser } = useAppSelector((state) => state.auth);
const [summary, setSummary] = useState(null);
const [subscriptionStatus, setSubscriptionStatus] = useState(null);
const [selectedBusinessId, setSelectedBusinessId] = useState('');
@@ -384,6 +455,8 @@ export default function SetupPage() {
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
+ const [businessInfoEditing, setBusinessInfoEditing] = useState(true);
+ const [isCreatingBusinessProfile, setIsCreatingBusinessProfile] = useState(false);
const [selectedReviewDestinations, setSelectedReviewDestinations] = useState([
'google',
]);
@@ -392,10 +465,14 @@ export default function SetupPage() {
const businesses = summary?.businesses || [];
const recentEvents = summary?.recentEvents || [];
const recentTransactions = summary?.recentTransactions || [];
- const selectedBusiness = useMemo(
- () => businesses.find((business) => business.id === selectedBusinessId) || summary?.primaryBusiness || null,
- [businesses, selectedBusinessId, summary?.primaryBusiness],
- );
+ const selectedBusiness = useMemo(() => {
+ if (isCreatingBusinessProfile) return null;
+ if (selectedBusinessId) {
+ return businesses.find((business) => business.id === selectedBusinessId) || null;
+ }
+
+ return summary?.primaryBusiness || null;
+ }, [businesses, isCreatingBusinessProfile, selectedBusinessId, summary?.primaryBusiness]);
const currentBusinessType = normalizeBusinessType(settingsForm.businessType);
const destinationOptions = getDestinationsForBusinessType(currentBusinessType);
const selectedDestinationOptions = getSelectedDestinationOptions(
@@ -406,6 +483,14 @@ export default function SetupPage() {
const selectedReviewDestination = getReviewDestinationOption(primaryReviewDestinationKey) || destinationOptions[0];
const selectedDestinationField = selectedReviewDestination?.field || '';
const brandedMessagingLocked = subscriptionStatus?.subscription.planId === 'starter';
+ const currentUserName = [currentUser?.firstName, currentUser?.lastName].filter(Boolean).join(' ');
+ const ownerLabel = currentUserName || currentUser?.email || 'Current user';
+ const businessLimit = Number(subscriptionStatus?.limits?.businesses ?? 1);
+ const businessCount = Number(subscriptionStatus?.usage?.businesses ?? businesses.length);
+ const isMultiBusinessPlan = businessLimit > 1;
+ const canStartNewBusiness = businessCount < businessLimit;
+ const canAddAnotherBusiness = isMultiBusinessPlan && canStartNewBusiness;
+ const businessLimitText = `${subscriptionStatus?.subscription.planName || 'Your plan'} includes ${businessLimit} ${businessLimit === 1 ? 'business profile' : 'business profiles'}.`;
const previewReplacements = useMemo(() => ({
businessName: settingsForm.businessName || 'Your Business',
customerName: 'Riley',
@@ -419,7 +504,7 @@ export default function SetupPage() {
const emailPreviewFooter = renderPreviewTemplate(settingsForm.emailFooterText, previewReplacements);
const smsPreview = renderPreviewTemplate(settingsForm.smsTemplate, previewReplacements);
- const hasBusinessInfo = Boolean(settingsForm.businessName.trim());
+ const hasBusinessInfo = Boolean(!isCreatingBusinessProfile && selectedBusinessId && settingsForm.businessName.trim());
const hasPaymentConnection = Boolean(
selectedBusiness?.id && selectedBusiness.providers?.some((provider: any) => provider.connected),
);
@@ -445,7 +530,7 @@ export default function SetupPage() {
},
];
- const loadData = async () => {
+ const loadData = async (options: { startNewBusiness?: boolean } = {}) => {
setIsLoading(true);
setError('');
@@ -455,17 +540,48 @@ export default function SetupPage() {
axios.get('/subscription/me'),
]);
const loadedSummary = summaryResponse.data as SummaryResponse;
- const primaryBusiness = loadedSummary.primaryBusiness || loadedSummary.businesses?.[0] || null;
+ const loadedSubscription = subscriptionResponse.data as SubscriptionStatusResponse;
+ const loadedBusinesses = loadedSummary.businesses || [];
+ const primaryBusiness = loadedSummary.primaryBusiness || loadedBusinesses[0] || null;
+ const loadedBusinessLimit = Number(loadedSubscription.limits?.businesses ?? 1);
+ const loadedBusinessCount = Number(loadedSubscription.usage?.businesses ?? loadedBusinesses.length);
+ const loadedPlanName = loadedSubscription.subscription.planName || 'Your plan';
setSummary(loadedSummary);
- setSubscriptionStatus(subscriptionResponse.data);
+ setSubscriptionStatus(loadedSubscription);
+
+ if (options.startNewBusiness) {
+ if (loadedBusinessCount < loadedBusinessLimit) {
+ const blankSettings = getBlankBusinessSettings(currentUser?.id || '');
+ setSelectedBusinessId('');
+ setSettingsForm(blankSettings);
+ setSelectedReviewDestinations(['google']);
+ setReviewOutputFinished(false);
+ setBusinessInfoEditing(true);
+ setIsCreatingBusinessProfile(true);
+ return;
+ }
+
+ setError(`${loadedPlanName} includes ${loadedBusinessLimit} ${loadedBusinessLimit === 1 ? 'business profile' : 'business profiles'}. Edit your existing business profile or upgrade before adding another.`);
+ }
if (primaryBusiness) {
const nextSettings = businessToSettings(primaryBusiness);
+ const nextSelectedDestinations = getSelectedDestinationsFromSettings(nextSettings);
setSelectedBusinessId(primaryBusiness.id);
setSettingsForm(nextSettings);
- setSelectedReviewDestinations(getSelectedDestinationsFromSettings(nextSettings));
- setReviewOutputFinished(getReviewOutputIsComplete(nextSettings, getSelectedDestinationsFromSettings(nextSettings)));
+ setSelectedReviewDestinations(nextSelectedDestinations);
+ setReviewOutputFinished(getReviewOutputIsComplete(nextSettings, nextSelectedDestinations));
+ setBusinessInfoEditing(false);
+ setIsCreatingBusinessProfile(false);
+ } else {
+ const blankSettings = getBlankBusinessSettings(currentUser?.id || '');
+ setSelectedBusinessId('');
+ setSettingsForm(blankSettings);
+ setSelectedReviewDestinations(['google']);
+ setReviewOutputFinished(false);
+ setBusinessInfoEditing(true);
+ setIsCreatingBusinessProfile(true);
}
} catch (requestError) {
console.error('Failed to load Setup:', requestError);
@@ -476,8 +592,10 @@ export default function SetupPage() {
};
useEffect(() => {
- loadData();
- }, []);
+ if (!router.isReady) return;
+
+ loadData({ startNewBusiness: router.query.businessMode === 'new' });
+ }, [router.isReady, router.query.businessMode, currentUser?.id]);
const updateSettings = (key: SetupSettingsKey, value: string | boolean) => {
setSettingsForm((current) => {
@@ -502,6 +620,22 @@ export default function SetupPage() {
};
}
+ if (key === 'defaultReviewPlatform') {
+ const nextReviewDestination = coerceDestination(current.businessType, String(value));
+
+ if (isReviewDestinationKey(nextReviewDestination)) {
+ setSelectedReviewDestinations([nextReviewDestination]);
+ }
+
+ setReviewOutputFinished(false);
+
+ return {
+ ...current,
+ defaultReviewPlatform: String(value),
+ reviewDestination: nextReviewDestination,
+ };
+ }
+
if (key === 'reviewDestination') {
setReviewOutputFinished(false);
}
@@ -512,11 +646,38 @@ export default function SetupPage() {
const selectBusiness = (businessId: string) => {
const business = businesses.find((item) => item.id === businessId);
+
+ if (!business) return;
+
const nextSettings = businessToSettings(business);
+ const nextSelectedDestinations = getSelectedDestinationsFromSettings(nextSettings);
setSelectedBusinessId(businessId);
setSettingsForm(nextSettings);
- setSelectedReviewDestinations(getSelectedDestinationsFromSettings(nextSettings));
- setReviewOutputFinished(getReviewOutputIsComplete(nextSettings, getSelectedDestinationsFromSettings(nextSettings)));
+ setSelectedReviewDestinations(nextSelectedDestinations);
+ setReviewOutputFinished(getReviewOutputIsComplete(nextSettings, nextSelectedDestinations));
+ setBusinessInfoEditing(false);
+ setIsCreatingBusinessProfile(false);
+ };
+
+ const startNewBusinessProfile = () => {
+ if (!canStartNewBusiness && businesses.length > 0) {
+ setError(`${businessLimitText} Edit an existing business profile or upgrade before adding another.`);
+ return;
+ }
+
+ const blankSettings = getBlankBusinessSettings(currentUser?.id || '');
+ setSelectedBusinessId('');
+ setSettingsForm(blankSettings);
+ setSelectedReviewDestinations(['google']);
+ setReviewOutputFinished(false);
+ setBusinessInfoEditing(true);
+ setIsCreatingBusinessProfile(true);
+ setMessage('');
+ setError('');
+
+ if (typeof document !== 'undefined') {
+ document.getElementById('business-info')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
};
const toggleReviewDestination = (destinationKey: ReviewDestinationKey) => {
@@ -563,11 +724,16 @@ export default function SetupPage() {
return true;
};
- const saveSettings = async (options: { finishReviewOutput?: boolean } = {}) => {
+ const saveSettings = async (options: { finishReviewOutput?: boolean; collapseBusinessInfo?: boolean } = {}) => {
if (options.finishReviewOutput) {
setMessage('');
}
+ if (!settingsForm.businessName.trim()) {
+ setError('Business name is required.');
+ return;
+ }
+
if (options.finishReviewOutput && !validateReviewOutput()) {
return;
}
@@ -579,22 +745,29 @@ export default function SetupPage() {
try {
const reviewDestination = getPrimaryReviewDestination(settingsForm, selectedReviewDestinations);
const response = await axios.put('/reviewflow/growth-tools/business', {
- businessId: selectedBusinessId,
+ businessId: isCreatingBusinessProfile ? '' : selectedBusinessId,
...settingsForm,
reviewDestination,
- delayDays: Number(settingsForm.delayDays),
- followupDelayDays: Number(settingsForm.followupDelayDays),
- maxFollowups: Number(settingsForm.maxFollowups),
+ delayDays: settingsForm.delayDays === '' ? '' : Number(settingsForm.delayDays),
+ followupDelayDays: settingsForm.followupDelayDays === '' ? '' : Number(settingsForm.followupDelayDays),
+ maxFollowups: settingsForm.maxFollowups === '' ? '' : Number(settingsForm.maxFollowups),
});
const business = response.data.business as ReviewBusiness;
const nextSettings = businessToSettings(business);
+ const nextSelectedDestinations = getSelectedDestinationsFromSettings(nextSettings);
setSelectedBusinessId(business.id);
setSettingsForm(nextSettings);
- setSelectedReviewDestinations(getSelectedDestinationsFromSettings(nextSettings));
+ setSelectedReviewDestinations(nextSelectedDestinations);
setReviewOutputFinished(Boolean(options.finishReviewOutput));
+ setIsCreatingBusinessProfile(false);
+ if (options.collapseBusinessInfo || isCreatingBusinessProfile) {
+ setBusinessInfoEditing(false);
+ }
setMessage(options.finishReviewOutput
? 'Review destinations saved to the company profile. Email and SMS messages will use these review links.'
- : 'Setup saved. Your Growth Tools now use these company, payment, and review-output defaults.');
+ : options.collapseBusinessInfo
+ ? 'Business info saved. The summary box is ready, and the next setup steps will use this business profile.'
+ : 'Setup saved. Your Growth Tools now use these company, payment, and review-output defaults.');
await loadData();
} catch (requestError) {
console.error('Failed to save Setup:', requestError);
@@ -616,6 +789,8 @@ export default function SetupPage() {
) => {
const businessType = normalizeBusinessType(connectorForm.businessType);
setSelectedBusinessId(business.id);
+ setIsCreatingBusinessProfile(false);
+ setBusinessInfoEditing(false);
setSettingsForm((current) => ({
...businessToSettings(business),
businessName: connectorForm.businessName || business.name || current.businessName,
@@ -623,7 +798,7 @@ export default function SetupPage() {
reviewDestination: coerceDestination(businessType, connectorForm.reviewDestination),
delayDays: connectorForm.delayDays || current.delayDays,
}));
- setMessage(`${connectorForm.provider.toUpperCase()} connected. Copy the generated webhook URL into that provider to finish the payment trigger.`);
+ setMessage(`${connectorForm.provider.toUpperCase()} setup complete. Test transaction received, and the payment trigger summary is ready.`);
await loadData();
};
@@ -692,80 +867,233 @@ export default function SetupPage() {
))}
-
+
Step 1
Business info
- This tells the app who the business is and which workflow type should be shown. For your logistics/transportation app, most companies will usually start as Local or Hybrid.
+ Add the business profile the same way the Businesses > Add Business page collects it. Grow accounts can save one business profile; Pro accounts can add more based on the plan limit.
-
+
loadData()} disabled={isLoading} />
- {businesses.length > 0 && (
-
-
-
+
+ {businessLimitText}{' '}
+ {isMultiBusinessPlan
+ ? `You currently have ${businessCount} saved ${businessCount === 1 ? 'business' : 'businesses'}.`
+ : 'Use the Edit button to update your existing Grow business profile.'}
+
+
+ {!businessInfoEditing && selectedBusiness ? (
+
+
+
+
Saved business summary
+
{settingsForm.businessName || selectedBusiness.name || 'Business'}
+
+ This is the business profile that payment connections, review links, and automated review requests will use.
+
+
+
+ setBusinessInfoEditing(true)} />
+ {isMultiBusinessPlan && (
+
+ )}
+
+
+
+ {businesses.length > 1 && (
+
+
+
+
+
+ )}
+
+
+
+
+
Business type
+
{getBusinessTypeLabel(currentBusinessType)}
+
+
+
Active
+
{settingsForm.isActive ? 'Yes' : 'No'}
+
+
+
Review delay days
+
{settingsForm.delayDays || 'Not set'}
+
+
+
Default review platform
+
{getDefaultReviewPlatformLabel(settingsForm.defaultReviewPlatform)}
+
+
+
Stripe connected
+
{settingsForm.stripeConnected ? 'Yes' : 'No'}
+
+
+
+
+
+
Review links
+
Google: {settingsForm.googleReviewLink || 'Not set'}
+
Yelp: {settingsForm.yelpReviewLink || 'Not set'}
+
Facebook: {settingsForm.facebookReviewLink || 'Not set'}
+
Custom: {settingsForm.customReviewLink || 'Not set'}
+
+
+
Stripe details
+
Account reference: {settingsForm.stripeAccountReference || 'Not set'}
+
Connected at: {settingsForm.stripeConnectedAt ? formatDate(settingsForm.stripeConnectedAt) : 'Not set'}
+ {!canAddAnotherBusiness && isMultiBusinessPlan && (
+
Business limit reached for this plan.
+ )}
+
+
+
+ ) : (
+
+ {isCreatingBusinessProfile ? (
+
+ New business profile. Save this form to create the summary box before connecting payment systems.
+
+ ) : businesses.length > 0 && (
+
+
+
+ )}
+
+
+
+
+
+
+ updateSettings('businessName', event.target.value)}
+ placeholder='Business name'
+ />
+
+
+
+
+
+
+
+ updateSettings('googleReviewLink', event.target.value)} placeholder='Google review link' />
+
+
+
+ updateSettings('yelpReviewLink', event.target.value)} placeholder='Yelp review link' />
+
+
+
+ updateSettings('facebookReviewLink', event.target.value)} placeholder='Facebook review link' />
+
+
+
+ updateSettings('delayDays', event.target.value)}
+ placeholder='Review delay days'
+ />
+
+
+
+ updateSettings('emailSubjectTemplate', event.target.value)} placeholder='Email subject template' />
+
+
+
+
+
+
+
+
+ updateSettings('stripeAccountReference', event.target.value)} placeholder='Stripe account reference' />
+
+
+
+
+
+ updateSettings('stripeConnectedAt', event.target.value)} placeholder='Stripe connected at' />
+
+
+
+
+
+
+
+ updateSettings('customReviewLink', event.target.value)} placeholder='Custom review link' />
+
+
+
+ saveSettings({ collapseBusinessInfo: true })} disabled={isSaving} />
+ {
+ if (isCreatingBusinessProfile) {
+ const blankSettings = getBlankBusinessSettings(currentUser?.id || '');
+ setSettingsForm(blankSettings);
+ setSelectedReviewDestinations(['google']);
+ setReviewOutputFinished(false);
+ return;
+ }
+
+ if (selectedBusiness) {
+ const nextSettings = businessToSettings(selectedBusiness);
+ const nextSelectedDestinations = getSelectedDestinationsFromSettings(nextSettings);
+ setSettingsForm(nextSettings);
+ setSelectedReviewDestinations(nextSelectedDestinations);
+ setReviewOutputFinished(getReviewOutputIsComplete(nextSettings, nextSelectedDestinations));
+ }
+ }}
+ />
+ selectedBusiness ? setBusinessInfoEditing(false) : loadData()} />
+
+
)}
-
- 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'
- />
-
-
-
@@ -773,7 +1101,12 @@ export default function SetupPage() {
eyebrow='Step 2'
title='Payment system connect'
description='Connect the payment/order provider that should trigger review requests automatically after completed jobs, payments, or orders.'
+ initialBusinessId={selectedBusinessId || selectedBusiness?.id || ''}
+ initialBusinessName={settingsForm.businessName}
initialBusinessType={currentBusinessType}
+ initialReviewDestination={primaryReviewDestinationKey}
+ initialReviewLink={selectedDestinationField ? String(settingsForm[selectedDestinationField as SetupSettingsKey] || '') : ''}
+ initialDelayDays={settingsForm.delayDays}
onConnected={handleProviderConnected}
/>