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