Autosave: 20260630-024520
This commit is contained in:
parent
4df4505096
commit
750d472c37
@ -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, '&')
|
||||
|
||||
@ -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 = {
|
||||
|
||||
448
backend/src/services/reviewflow-oauth.js
Normal file
448
backend/src/services/reviewflow-oauth.js
Normal file
@ -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 `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<style>
|
||||
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f8fafc; color: #0f172a; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||
main { width: min(680px, calc(100% - 32px)); border: 1px solid #e2e8f0; border-radius: 28px; background: #fff; padding: 32px; box-shadow: 0 24px 80px rgba(15, 23, 42, 0.12); }
|
||||
.eyebrow { color: ${accent}; font-size: 12px; font-weight: 900; letter-spacing: .22em; text-transform: uppercase; }
|
||||
h1 { margin: 10px 0 12px; font-size: clamp(28px, 5vw, 42px); line-height: 1; }
|
||||
p { color: #475569; line-height: 1.7; }
|
||||
.badge { display: inline-flex; margin-top: 12px; border-radius: 999px; background: #ecfdf5; color: #047857; padding: 8px 12px; font-weight: 900; font-size: 13px; }
|
||||
a { display: inline-flex; margin-top: 20px; border-radius: 999px; background: #4f46e5; color: white; padding: 12px 18px; text-decoration: none; font-weight: 900; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="eyebrow">ReviewFlow payment backup</div>
|
||||
<h1>${escapeHtml(title)}</h1>
|
||||
<p>${escapeHtml(result.message)}</p>
|
||||
<div class="badge">${escapeHtml(result.label)} · Webhook primary · OAuth backup</div>
|
||||
<br />
|
||||
<a href="/connect">Return to payment setup</a>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createAuthorizationStart,
|
||||
getCallbackInput,
|
||||
getOAuthStatus,
|
||||
handleOAuthCallback,
|
||||
renderCallbackHtml,
|
||||
};
|
||||
@ -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,
|
||||
|
||||
209
frontend/src/components/FloatingAiHelper.tsx
Normal file
209
frontend/src/components/FloatingAiHelper.tsx
Normal file
@ -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<ChatMessage[]>([
|
||||
{
|
||||
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 (
|
||||
<div className='fixed bottom-5 right-5 z-[70] flex flex-col items-end gap-3'>
|
||||
{!isOpen && isSetupRoute && !dismissedNudge ? (
|
||||
<div className='max-w-xs rounded-3xl border border-indigo-200 bg-white p-4 text-sm text-slate-700 shadow-2xl dark:border-indigo-900 dark:bg-dark-900 dark:text-slate-100'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<BaseIcon path={mdiRobotHappyOutline} className='mt-0.5 shrink-0 text-indigo-600 dark:text-indigo-300' />
|
||||
<div>
|
||||
<p className='font-black text-slate-900 dark:text-white'>Need setup help?</p>
|
||||
<p className='mt-1 leading-5'>Click the AI helper any time for first-this-then-this guidance.</p>
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Dismiss AI helper reminder'
|
||||
className='ml-1 rounded-full p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-dark-800 dark:hover:text-white'
|
||||
onClick={() => setDismissedNudge(true)}
|
||||
>
|
||||
<BaseIcon path={mdiClose} size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isOpen ? (
|
||||
<div className='w-[calc(100vw-2rem)] max-w-md overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-2xl dark:border-dark-700 dark:bg-dark-900'>
|
||||
<div className='flex items-center justify-between bg-gradient-to-r from-indigo-700 to-sky-600 p-4 text-white'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<span className='flex h-10 w-10 items-center justify-center rounded-2xl bg-white/15'>
|
||||
<BaseIcon path={mdiRobotHappyOutline} />
|
||||
</span>
|
||||
<div>
|
||||
<p className='font-black'>ReviewFlow AI Helper</p>
|
||||
<p className='text-xs text-indigo-100'>Ask about setup, payments, reviews, or next steps.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Close AI helper'
|
||||
className='rounded-full p-2 hover:bg-white/15'
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<BaseIcon path={mdiClose} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='max-h-[55vh] space-y-3 overflow-y-auto p-4'>
|
||||
{messages.map((message, index) => (
|
||||
<div
|
||||
key={`${message.role}-${index}`}
|
||||
className={`rounded-3xl p-3 text-sm leading-6 ${message.role === 'user'
|
||||
? 'ml-8 bg-indigo-600 text-white'
|
||||
: 'mr-8 bg-slate-100 text-slate-800 dark:bg-dark-800 dark:text-slate-100'}`}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
))}
|
||||
{isSending || isAskingResponse ? (
|
||||
<div className='mr-8 rounded-3xl bg-slate-100 p-3 text-sm font-semibold text-slate-500 dark:bg-dark-800 dark:text-slate-300'>
|
||||
Thinking…
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<form onSubmit={submitMessage} className='border-t border-slate-200 p-3 dark:border-dark-700'>
|
||||
<div className='flex gap-2'>
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
placeholder='Ask the AI helper…'
|
||||
className='h-14 min-h-14 flex-1 rounded-2xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:border-dark-700 dark:bg-dark-800 dark:text-white'
|
||||
/>
|
||||
<button
|
||||
type='submit'
|
||||
disabled={!draft.trim() || isSending}
|
||||
className='flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-indigo-600 text-white shadow-lg transition hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
>
|
||||
<BaseIcon path={mdiSend} />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Open ReviewFlow AI helper'
|
||||
className='flex h-16 w-16 items-center justify-center rounded-full bg-indigo-600 text-white shadow-2xl ring-4 ring-white transition hover:-translate-y-0.5 hover:bg-indigo-700 dark:ring-dark-800'
|
||||
onClick={() => {
|
||||
setIsOpen((current) => !current);
|
||||
setDismissedNudge(true);
|
||||
}}
|
||||
>
|
||||
<BaseIcon path={isOpen ? mdiClose : mdiChatQuestionOutline} size={30} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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}
|
||||
<FooterBar>{portalLabel} · ReviewFlow</FooterBar>
|
||||
<FloatingAiHelper />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -115,7 +115,7 @@ const BusinessesTablesPage = () => {
|
||||
/>
|
||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||
|
||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/businesses/businesses-new'} color='info' label='Add Business'/>}
|
||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/setup?businessMode=new#business-info'} color='info' label='Add Business'/>}
|
||||
|
||||
<BaseButton
|
||||
className={'mr-3'}
|
||||
|
||||
@ -115,7 +115,7 @@ const BusinessesTablesPage = () => {
|
||||
/>
|
||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||
|
||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/businesses/businesses-new'} color='info' label='Add Business'/>}
|
||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/setup?businessMode=new#business-info'} color='info' label='Add Business'/>}
|
||||
|
||||
<BaseButton
|
||||
className={'mr-3'}
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
} from '@mdi/js';
|
||||
import axios from 'axios';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
|
||||
import PaymentProviderConnectors, {
|
||||
ConnectorFormValues,
|
||||
@ -18,12 +19,19 @@ import SectionMain from '../components/SectionMain';
|
||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
import { getPageTitle } from '../config';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
|
||||
type BusinessType = 'local' | 'online' | 'hybrid';
|
||||
|
||||
type ReviewBusiness = {
|
||||
id: string;
|
||||
name?: string;
|
||||
ownerId?: string;
|
||||
is_active?: boolean;
|
||||
stripe_account_reference?: string;
|
||||
stripe_connected?: boolean;
|
||||
stripe_connected_at?: string | null;
|
||||
default_review_platform?: string;
|
||||
business_type?: BusinessType;
|
||||
review_destination?: string;
|
||||
delay_days?: number;
|
||||
@ -103,6 +111,12 @@ type SubscriptionStatusResponse = {
|
||||
effectiveStatus: string;
|
||||
isActive: boolean;
|
||||
};
|
||||
usage?: {
|
||||
businesses?: number;
|
||||
} | null;
|
||||
limits?: {
|
||||
businesses?: number;
|
||||
};
|
||||
};
|
||||
|
||||
const businessTypeOptions: Array<{ key: BusinessType; label: string; help: string }> = [
|
||||
@ -139,6 +153,7 @@ type ReviewDestinationKey = ReviewDestinationOption['key'];
|
||||
type ReviewDestinationField = Exclude<ReviewDestinationOption['field'], ''>;
|
||||
|
||||
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<string, string> = {
|
||||
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<SummaryResponse | null>(null);
|
||||
const [subscriptionStatus, setSubscriptionStatus] = useState<SubscriptionStatusResponse | null>(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<ReviewDestinationKey[]>([
|
||||
'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() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<CardBox className='mb-6 border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
|
||||
<CardBox id='business-info' className='mb-6 border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
|
||||
<div className='mb-5 flex flex-wrap items-start justify-between gap-4'>
|
||||
<div>
|
||||
<p className='text-sm font-bold uppercase tracking-[0.25em] text-emerald-500'>Step 1</p>
|
||||
<h3 className='mt-1 text-2xl font-black text-slate-900 dark:text-white'>Business info</h3>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400'>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<BaseButton icon={mdiRefresh} label='Refresh' color='whiteDark' onClick={loadData} disabled={isLoading} />
|
||||
<BaseButton icon={mdiRefresh} label='Refresh' color='whiteDark' onClick={() => loadData()} disabled={isLoading} />
|
||||
</div>
|
||||
|
||||
{businesses.length > 0 && (
|
||||
<FormField label='Business profile' help='Choose the company/location you want to configure.'>
|
||||
<select value={selectedBusinessId} onChange={(event) => selectBusiness(event.target.value)}>
|
||||
{businesses.map((business) => (
|
||||
<option key={business.id} value={business.id}>{business.name || 'Business'}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<div className='mb-5 rounded-2xl border border-indigo-100 bg-indigo-50 p-4 text-sm leading-6 text-indigo-950 dark:border-indigo-900 dark:bg-indigo-950/30 dark:text-indigo-100'>
|
||||
<strong>{businessLimitText}</strong>{' '}
|
||||
{isMultiBusinessPlan
|
||||
? `You currently have ${businessCount} saved ${businessCount === 1 ? 'business' : 'businesses'}.`
|
||||
: 'Use the Edit button to update your existing Grow business profile.'}
|
||||
</div>
|
||||
|
||||
{!businessInfoEditing && selectedBusiness ? (
|
||||
<div className='rounded-3xl border border-emerald-200 bg-emerald-50 p-5 text-emerald-950 dark:border-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-50'>
|
||||
<div className='flex flex-wrap items-start justify-between gap-4'>
|
||||
<div>
|
||||
<p className='text-sm font-black uppercase tracking-[0.25em] text-emerald-700 dark:text-emerald-300'>Saved business summary</p>
|
||||
<h4 className='mt-1 text-2xl font-black'>{settingsForm.businessName || selectedBusiness.name || 'Business'}</h4>
|
||||
<p className='mt-2 max-w-2xl text-sm leading-6'>
|
||||
This is the business profile that payment connections, review links, and automated review requests will use.
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<BaseButton label='Edit' color='whiteDark' onClick={() => setBusinessInfoEditing(true)} />
|
||||
{isMultiBusinessPlan && (
|
||||
<BaseButton label='Add Another Business' color='info' onClick={startNewBusinessProfile} disabled={!canAddAnotherBusiness} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{businesses.length > 1 && (
|
||||
<div className='mt-5 rounded-2xl bg-white/80 p-4 shadow-sm dark:bg-dark-900/60'>
|
||||
<FormField label='Business profile' help='Choose another saved company/location to configure.'>
|
||||
<select value={selectedBusinessId} onChange={(event) => selectBusiness(event.target.value)}>
|
||||
{businesses.map((business) => (
|
||||
<option key={business.id} value={business.id}>{business.name || 'Business'}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-3'>
|
||||
<div className='rounded-2xl bg-white/80 p-4 text-sm shadow-sm dark:bg-dark-900/60'>
|
||||
<p className='font-black'>Owner</p>
|
||||
<p className='mt-1'>{ownerLabel}</p>
|
||||
</div>
|
||||
<div className='rounded-2xl bg-white/80 p-4 text-sm shadow-sm dark:bg-dark-900/60'>
|
||||
<p className='font-black'>Business type</p>
|
||||
<p className='mt-1'>{getBusinessTypeLabel(currentBusinessType)}</p>
|
||||
</div>
|
||||
<div className='rounded-2xl bg-white/80 p-4 text-sm shadow-sm dark:bg-dark-900/60'>
|
||||
<p className='font-black'>Active</p>
|
||||
<p className='mt-1'>{settingsForm.isActive ? 'Yes' : 'No'}</p>
|
||||
</div>
|
||||
<div className='rounded-2xl bg-white/80 p-4 text-sm shadow-sm dark:bg-dark-900/60'>
|
||||
<p className='font-black'>Review delay days</p>
|
||||
<p className='mt-1'>{settingsForm.delayDays || 'Not set'}</p>
|
||||
</div>
|
||||
<div className='rounded-2xl bg-white/80 p-4 text-sm shadow-sm dark:bg-dark-900/60'>
|
||||
<p className='font-black'>Default review platform</p>
|
||||
<p className='mt-1'>{getDefaultReviewPlatformLabel(settingsForm.defaultReviewPlatform)}</p>
|
||||
</div>
|
||||
<div className='rounded-2xl bg-white/80 p-4 text-sm shadow-sm dark:bg-dark-900/60'>
|
||||
<p className='font-black'>Stripe connected</p>
|
||||
<p className='mt-1'>{settingsForm.stripeConnected ? 'Yes' : 'No'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-5 grid gap-3 md:grid-cols-2'>
|
||||
<div className='rounded-2xl bg-white/80 p-4 text-sm shadow-sm dark:bg-dark-900/60'>
|
||||
<p className='font-black'>Review links</p>
|
||||
<p className='mt-2 break-all'>Google: {settingsForm.googleReviewLink || 'Not set'}</p>
|
||||
<p className='mt-1 break-all'>Yelp: {settingsForm.yelpReviewLink || 'Not set'}</p>
|
||||
<p className='mt-1 break-all'>Facebook: {settingsForm.facebookReviewLink || 'Not set'}</p>
|
||||
<p className='mt-1 break-all'>Custom: {settingsForm.customReviewLink || 'Not set'}</p>
|
||||
</div>
|
||||
<div className='rounded-2xl bg-white/80 p-4 text-sm shadow-sm dark:bg-dark-900/60'>
|
||||
<p className='font-black'>Stripe details</p>
|
||||
<p className='mt-2 break-all'>Account reference: {settingsForm.stripeAccountReference || 'Not set'}</p>
|
||||
<p className='mt-1'>Connected at: {settingsForm.stripeConnectedAt ? formatDate(settingsForm.stripeConnectedAt) : 'Not set'}</p>
|
||||
{!canAddAnotherBusiness && isMultiBusinessPlan && (
|
||||
<p className='mt-3 text-xs font-bold opacity-80'>Business limit reached for this plan.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{isCreatingBusinessProfile ? (
|
||||
<div className='mb-5 rounded-2xl border border-dashed border-emerald-300 bg-emerald-50 p-4 text-sm font-bold text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-100'>
|
||||
New business profile. Save this form to create the summary box before connecting payment systems.
|
||||
</div>
|
||||
) : businesses.length > 0 && (
|
||||
<FormField label='Business profile' help='Choose the company/location you want to configure.'>
|
||||
<select value={selectedBusinessId} onChange={(event) => selectBusiness(event.target.value)}>
|
||||
{businesses.map((business) => (
|
||||
<option key={business.id} value={business.id}>{business.name || 'Business'}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<FormField label='Owner' labelFor='ownerId'>
|
||||
<select id='ownerId' value={settingsForm.ownerId || currentUser?.id || ''} onChange={(event) => updateSettings('ownerId', event.target.value)}>
|
||||
<option value={settingsForm.ownerId || currentUser?.id || ''}>{ownerLabel}</option>
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<FormField label='Business name'>
|
||||
<input
|
||||
required
|
||||
value={settingsForm.businessName}
|
||||
onChange={(event) => updateSettings('businessName', event.target.value)}
|
||||
placeholder='Business name'
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label='Business type' labelFor='businessType' help='Choose Local, Online, or Hybrid so Review Flow hides irrelevant setup options.'>
|
||||
<select id='businessType' value={settingsForm.businessType} onChange={(event) => updateSettings('businessType', event.target.value)}>
|
||||
{businessTypeOptions.map((option) => (
|
||||
<option key={option.key} value={option.key}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<FormField label='Google review link'>
|
||||
<input value={settingsForm.googleReviewLink} onChange={(event) => updateSettings('googleReviewLink', event.target.value)} placeholder='Google review link' />
|
||||
</FormField>
|
||||
|
||||
<FormField label='Yelp review link'>
|
||||
<input value={settingsForm.yelpReviewLink} onChange={(event) => updateSettings('yelpReviewLink', event.target.value)} placeholder='Yelp review link' />
|
||||
</FormField>
|
||||
|
||||
<FormField label='Facebook review link'>
|
||||
<input value={settingsForm.facebookReviewLink} onChange={(event) => updateSettings('facebookReviewLink', event.target.value)} placeholder='Facebook review link' />
|
||||
</FormField>
|
||||
|
||||
<FormField label='Review delay days'>
|
||||
<input
|
||||
type='number'
|
||||
value={settingsForm.delayDays}
|
||||
onChange={(event) => updateSettings('delayDays', event.target.value)}
|
||||
placeholder='Review delay days'
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label='Email subject template'>
|
||||
<input value={settingsForm.emailSubjectTemplate} onChange={(event) => updateSettings('emailSubjectTemplate', event.target.value)} placeholder='Email subject template' />
|
||||
</FormField>
|
||||
|
||||
<FormField label='Email body template' hasTextareaHeight>
|
||||
<textarea value={settingsForm.emailBodyTemplate} onChange={(event) => updateSettings('emailBodyTemplate', event.target.value)} placeholder='Email body template' />
|
||||
</FormField>
|
||||
|
||||
<label className='mb-4 flex gap-3 rounded-2xl border border-slate-200 p-4 text-sm dark:border-dark-700'>
|
||||
<input type='checkbox' checked={settingsForm.isActive} onChange={(event) => updateSettings('isActive', event.target.checked)} />
|
||||
<span>
|
||||
<span className='block font-black text-slate-900 dark:text-white'>Active</span>
|
||||
<span className='mt-1 block leading-5 text-slate-500 dark:text-slate-400'>When active, this profile can be used by setup workflows and automated review requests.</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<FormField label='Stripe account reference'>
|
||||
<input value={settingsForm.stripeAccountReference} onChange={(event) => updateSettings('stripeAccountReference', event.target.value)} placeholder='Stripe account reference' />
|
||||
</FormField>
|
||||
|
||||
<label className='mb-4 flex gap-3 rounded-2xl border border-slate-200 p-4 text-sm dark:border-dark-700'>
|
||||
<input type='checkbox' checked={settingsForm.stripeConnected} onChange={(event) => updateSettings('stripeConnected', event.target.checked)} />
|
||||
<span>
|
||||
<span className='block font-black text-slate-900 dark:text-white'>Stripe connected</span>
|
||||
<span className='mt-1 block leading-5 text-slate-500 dark:text-slate-400'>Use this only if this business already has a Stripe connection.</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<FormField label='Stripe connected at'>
|
||||
<input type='datetime-local' value={settingsForm.stripeConnectedAt} onChange={(event) => updateSettings('stripeConnectedAt', event.target.value)} placeholder='Stripe connected at' />
|
||||
</FormField>
|
||||
|
||||
<FormField label='Default review platform' labelFor='defaultReviewPlatform'>
|
||||
<select id='defaultReviewPlatform' value={settingsForm.defaultReviewPlatform} onChange={(event) => updateSettings('defaultReviewPlatform', event.target.value)}>
|
||||
<option value='google'>google</option>
|
||||
<option value='yelp'>yelp</option>
|
||||
<option value='facebook'>facebook</option>
|
||||
<option value='custom'>custom</option>
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<FormField label='Custom review link'>
|
||||
<input value={settingsForm.customReviewLink} onChange={(event) => updateSettings('customReviewLink', event.target.value)} placeholder='Custom review link' />
|
||||
</FormField>
|
||||
|
||||
<div className='mt-6 flex flex-wrap gap-3 border-t border-slate-100 pt-5 dark:border-dark-700'>
|
||||
<BaseButton label={isSaving ? 'Saving business...' : 'Save Business Info'} color='info' onClick={() => saveSettings({ collapseBusinessInfo: true })} disabled={isSaving} />
|
||||
<BaseButton
|
||||
label='Reset'
|
||||
color='info'
|
||||
outline
|
||||
onClick={() => {
|
||||
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));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<BaseButton label='Cancel' color='danger' outline onClick={() => selectedBusiness ? setBusinessInfoEditing(false) : loadData()} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField label='Company and workflow type' help={businessTypeOptions.find((option) => option.key === currentBusinessType)?.help}>
|
||||
<input
|
||||
required
|
||||
value={settingsForm.businessName}
|
||||
onChange={(event) => updateSettings('businessName', event.target.value)}
|
||||
placeholder='Business name'
|
||||
/>
|
||||
<select value={settingsForm.businessType} onChange={(event) => updateSettings('businessType', event.target.value)}>
|
||||
{businessTypeOptions.map((option) => (
|
||||
<option key={option.key} value={option.key}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<FormField label='Default request timing' help='These defaults are used by automated requests created from payment/order triggers.'>
|
||||
<input
|
||||
min='0'
|
||||
max='30'
|
||||
type='number'
|
||||
value={settingsForm.delayDays}
|
||||
onChange={(event) => updateSettings('delayDays', event.target.value)}
|
||||
placeholder='Initial delay days'
|
||||
/>
|
||||
<input
|
||||
min='1'
|
||||
max='30'
|
||||
type='number'
|
||||
value={settingsForm.followupDelayDays}
|
||||
onChange={(event) => updateSettings('followupDelayDays', event.target.value)}
|
||||
placeholder='Follow-up delay days'
|
||||
/>
|
||||
<input
|
||||
min='0'
|
||||
max='5'
|
||||
type='number'
|
||||
value={settingsForm.maxFollowups}
|
||||
onChange={(event) => updateSettings('maxFollowups', event.target.value)}
|
||||
placeholder='Max follow-ups'
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<label className='mb-2 flex gap-3 rounded-2xl border border-slate-200 p-4 text-sm dark:border-dark-700'>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={settingsForm.followupEnabled}
|
||||
onChange={(event) => updateSettings('followupEnabled', event.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<span className='block font-black text-slate-900 dark:text-white'>Enable follow-ups</span>
|
||||
<span className='mt-1 block leading-5 text-slate-500 dark:text-slate-400'>Prepare follow-up handoffs for customers who have not clicked the first request.</span>
|
||||
</span>
|
||||
</label>
|
||||
</CardBox>
|
||||
|
||||
<div className='mb-6'>
|
||||
@ -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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user