diff --git a/backend/src/db/api/review_requests.js b/backend/src/db/api/review_requests.js index 1b56374..9ac9e06 100644 --- a/backend/src/db/api/review_requests.js +++ b/backend/src/db/api/review_requests.js @@ -1,7 +1,5 @@ const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); const Utils = require('../utils'); @@ -382,15 +380,12 @@ module.exports = class Review_requestsDBApi { offset = currentPage * limit; - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - let include = [ { model: db.businesses, as: 'business', + required: Boolean(filter.business), where: filter.business ? { [Op.or]: [ @@ -408,6 +403,7 @@ module.exports = class Review_requestsDBApi { { model: db.customers, as: 'customer', + required: Boolean(filter.customer), where: filter.customer ? { [Op.or]: [ @@ -425,6 +421,7 @@ module.exports = class Review_requestsDBApi { { model: db.transactions, as: 'transaction', + required: Boolean(filter.transaction), where: filter.transaction ? { [Op.or]: [ diff --git a/backend/src/db/migrations/20260629030000-add-reviewflow-payment-webhooks.js b/backend/src/db/migrations/20260629030000-add-reviewflow-payment-webhooks.js new file mode 100644 index 0000000..3c873ac --- /dev/null +++ b/backend/src/db/migrations/20260629030000-add-reviewflow-payment-webhooks.js @@ -0,0 +1,112 @@ +'use strict'; + +const businessColumns = { + stripe_webhook_token: { type: 'TEXT' }, + square_account_reference: { type: 'TEXT' }, + square_connected: { type: 'BOOLEAN', defaultValue: false, allowNull: false }, + square_connected_at: { type: 'DATE' }, + square_webhook_token: { type: 'TEXT' }, + paypal_merchant_reference: { type: 'TEXT' }, + paypal_connected: { type: 'BOOLEAN', defaultValue: false, allowNull: false }, + paypal_connected_at: { type: 'DATE' }, + paypal_webhook_token: { type: 'TEXT' }, +}; + +const customerColumns = { + square_customer_reference: { type: 'TEXT' }, + paypal_customer_reference: { type: 'TEXT' }, +}; + +const transactionColumns = { + businessId: { type: 'UUID', references: { model: 'businesses', key: 'id' } }, + payment_provider: { type: 'TEXT' }, + square_payment_reference: { type: 'TEXT' }, + paypal_payment_reference: { type: 'TEXT' }, + provider_event_reference: { type: 'TEXT' }, +}; + +const eventColumns = { + provider: { type: 'TEXT' }, + provider_event_type: { type: 'TEXT' }, +}; + +function normalizeColumnDefinition(Sequelize, definition) { + const normalized = { ...definition }; + + if (definition.type === 'TEXT') { + normalized.type = Sequelize.DataTypes.TEXT; + } + + if (definition.type === 'BOOLEAN') { + normalized.type = Sequelize.DataTypes.BOOLEAN; + } + + if (definition.type === 'DATE') { + normalized.type = Sequelize.DataTypes.DATE; + } + + if (definition.type === 'UUID') { + normalized.type = Sequelize.DataTypes.UUID; + } + + return normalized; +} + +async function addColumnsIfMissing(queryInterface, Sequelize, transaction, tableName, columns) { + const table = await queryInterface.describeTable(tableName); + + for (const [columnName, definition] of Object.entries(columns)) { + if (!table[columnName]) { + await queryInterface.addColumn( + tableName, + columnName, + normalizeColumnDefinition(Sequelize, definition), + { transaction }, + ); + } + } +} + +async function removeColumnsIfPresent(queryInterface, transaction, tableName, columns) { + const table = await queryInterface.describeTable(tableName); + + for (const columnName of Object.keys(columns).reverse()) { + if (table[columnName]) { + await queryInterface.removeColumn(tableName, columnName, { transaction }); + } + } +} + +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'businesses', businessColumns); + await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'customers', customerColumns); + await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'transactions', transactionColumns); + await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'stripe_events', eventColumns); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + await removeColumnsIfPresent(queryInterface, transaction, 'stripe_events', eventColumns); + await removeColumnsIfPresent(queryInterface, transaction, 'transactions', transactionColumns); + await removeColumnsIfPresent(queryInterface, transaction, 'customers', customerColumns); + await removeColumnsIfPresent(queryInterface, transaction, 'businesses', businessColumns); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; diff --git a/backend/src/db/models/businesses.js b/backend/src/db/models/businesses.js index 8ae6850..8e87011 100644 --- a/backend/src/db/models/businesses.js +++ b/backend/src/db/models/businesses.js @@ -1,9 +1,3 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); - module.exports = function(sequelize, DataTypes) { const businesses = sequelize.define( 'businesses', @@ -95,6 +89,75 @@ stripe_connected_at: { + }, + +stripe_webhook_token: { + type: DataTypes.TEXT, + + + + }, + +square_account_reference: { + type: DataTypes.TEXT, + + + + }, + +square_connected: { + type: DataTypes.BOOLEAN, + + allowNull: false, + defaultValue: false, + + + + }, + +square_connected_at: { + type: DataTypes.DATE, + + + + }, + +square_webhook_token: { + type: DataTypes.TEXT, + + + + }, + +paypal_merchant_reference: { + type: DataTypes.TEXT, + + + + }, + +paypal_connected: { + type: DataTypes.BOOLEAN, + + allowNull: false, + defaultValue: false, + + + + }, + +paypal_connected_at: { + type: DataTypes.DATE, + + + + }, + +paypal_webhook_token: { + type: DataTypes.TEXT, + + + }, default_review_platform: { @@ -176,6 +239,14 @@ custom_review_link: { constraints: false, }); + db.businesses.hasMany(db.transactions, { + as: 'transactions_business', + foreignKey: { + name: 'businessId', + }, + constraints: false, + }); + diff --git a/backend/src/db/models/customers.js b/backend/src/db/models/customers.js index 4819aea..523a5fe 100644 --- a/backend/src/db/models/customers.js +++ b/backend/src/db/models/customers.js @@ -1,9 +1,3 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); - module.exports = function(sequelize, DataTypes) { const customers = sequelize.define( 'customers', @@ -40,6 +34,20 @@ stripe_customer_reference: { + }, + +square_customer_reference: { + type: DataTypes.TEXT, + + + + }, + +paypal_customer_reference: { + type: DataTypes.TEXT, + + + }, contact_status: { diff --git a/backend/src/db/models/stripe_events.js b/backend/src/db/models/stripe_events.js index 0302d7e..fd0e232 100644 --- a/backend/src/db/models/stripe_events.js +++ b/backend/src/db/models/stripe_events.js @@ -1,9 +1,3 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); - module.exports = function(sequelize, DataTypes) { const stripe_events = sequelize.define( 'stripe_events', @@ -19,6 +13,20 @@ stripe_event_reference: { + }, + +provider: { + type: DataTypes.TEXT, + + + + }, + +provider_event_type: { + type: DataTypes.TEXT, + + + }, event_type: { diff --git a/backend/src/db/models/transactions.js b/backend/src/db/models/transactions.js index b1af6b5..6972319 100644 --- a/backend/src/db/models/transactions.js +++ b/backend/src/db/models/transactions.js @@ -1,9 +1,3 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); - module.exports = function(sequelize, DataTypes) { const transactions = sequelize.define( 'transactions', @@ -19,6 +13,34 @@ stripe_payment_reference: { + }, + +payment_provider: { + type: DataTypes.TEXT, + + + + }, + +square_payment_reference: { + type: DataTypes.TEXT, + + + + }, + +paypal_payment_reference: { + type: DataTypes.TEXT, + + + + }, + +provider_event_reference: { + type: DataTypes.TEXT, + + + }, amount: { @@ -131,6 +153,14 @@ receipt_email: { constraints: false, }); + db.transactions.belongsTo(db.businesses, { + as: 'business', + foreignKey: { + name: 'businessId', + }, + constraints: false, + }); + diff --git a/backend/src/index.js b/backend/src/index.js index 57813fe..dd19b8a 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -6,7 +6,6 @@ const passport = require('passport'); const path = require('path'); const fs = require('fs'); const bodyParser = require('body-parser'); -const db = require('./db/models'); const config = require('./config'); const swaggerUI = require('swagger-ui-express'); const swaggerJsDoc = require('swagger-jsdoc'); @@ -35,6 +34,9 @@ const transactionsRoutes = require('./routes/transactions'); const review_requestsRoutes = require('./routes/review_requests'); +const reviewflowRoutes = require('./routes/reviewflow'); +const reviewflowWebhooksRoutes = require('./routes/reviewflow-webhooks'); + const stripe_eventsRoutes = require('./routes/stripe_events'); const email_delivery_logsRoutes = require('./routes/email_delivery_logs'); @@ -113,6 +115,10 @@ app.use('/api/transactions', passport.authenticate('jwt', {session: false}), tra app.use('/api/review_requests', passport.authenticate('jwt', {session: false}), review_requestsRoutes); +app.use('/api/reviewflow', passport.authenticate('jwt', {session: false}), reviewflowRoutes); + +app.use('/api/reviewflow-webhooks', reviewflowWebhooksRoutes); + app.use('/api/stripe_events', passport.authenticate('jwt', {session: false}), stripe_eventsRoutes); app.use('/api/email_delivery_logs', passport.authenticate('jwt', {session: false}), email_delivery_logsRoutes); diff --git a/backend/src/routes/reviewflow-webhooks.js b/backend/src/routes/reviewflow-webhooks.js new file mode 100644 index 0000000..3687bee --- /dev/null +++ b/backend/src/routes/reviewflow-webhooks.js @@ -0,0 +1,29 @@ +const express = require('express'); +const ReviewFlowService = require('../services/reviewflow'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +router.post('/:provider/:businessId/:secretToken', wrapAsync(async (req, res) => { + const result = await ReviewFlowService.processPaymentWebhook( + req.params.provider, + req.params.businessId, + req.params.secretToken, + req.body, + ); + + res.status(200).send({ received: true, ...result }); +})); + +router.get('/:provider/:businessId/:secretToken', wrapAsync(async (req, res) => { + ReviewFlowService.getProviderConfig(req.params.provider); + + res.status(200).send({ + ok: true, + message: 'ReviewFlow webhook URL is reachable. Configure your payment provider to POST JSON events to this same URL.', + }); +})); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/reviewflow.js b/backend/src/routes/reviewflow.js new file mode 100644 index 0000000..d9e7007 --- /dev/null +++ b/backend/src/routes/reviewflow.js @@ -0,0 +1,236 @@ +const express = require('express'); +const crypto = require('crypto'); +const db = require('../db/models'); +const ReviewFlowService = require('../services/reviewflow'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +function normalizeString(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +function requireField(value, message) { + if (!normalizeString(value)) { + const error = new Error(message); + error.code = 400; + throw error; + } +} + +function validateUrl(value, message) { + try { + const parsed = new URL(value); + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw new Error(message); + } + } catch { + const validationError = new Error(message); + validationError.code = 400; + throw validationError; + } +} + +function buildEmailBody(customerName, businessName, reviewLink) { + const greetingName = customerName || 'there'; + return [ + `Hi ${greetingName},`, + '', + `Thank you for choosing ${businessName}. We would love to hear about your experience.`, + '', + `Leave a review: ${reviewLink}`, + '', + `Thank you,`, + businessName, + ].join('\n'); +} + + +router.get('/connectors', wrapAsync(async (req, res) => { + const businesses = await ReviewFlowService.listConnectorBusinesses(req.currentUser, req); + + res.status(200).send({ businesses }); +})); + +router.post('/connectors', wrapAsync(async (req, res) => { + const business = await ReviewFlowService.connectProvider(req.currentUser, req.body || {}, req); + + res.status(200).send({ business }); +})); + +router.post('/connectors/:businessId/:provider/rotate', wrapAsync(async (req, res) => { + const business = await ReviewFlowService.rotateWebhookToken( + req.currentUser, + req.params.businessId, + req.params.provider, + req, + ); + + res.status(200).send({ business }); +})); + +router.get('/summary', wrapAsync(async (req, res) => { + const currentUser = req.currentUser; + const limit = Math.min(Number(req.query.limit) || 8, 25); + + const requests = await db.review_requests.findAll({ + where: { createdById: currentUser.id }, + include: [ + { model: db.businesses, as: 'business' }, + { model: db.customers, as: 'customer' }, + { model: db.transactions, as: 'transaction' }, + ], + order: [['createdAt', 'DESC']], + limit, + }); + + const [ + pending, + sent, + clicked, + reviewed, + customers, + transactions, + paymentEvents, + recentTransactions, + recentEvents, + ] = await Promise.all([ + db.review_requests.count({ where: { createdById: currentUser.id, status: 'pending' } }), + db.review_requests.count({ where: { createdById: currentUser.id, status: 'sent' } }), + db.review_requests.count({ where: { createdById: currentUser.id, status: 'clicked' } }), + db.review_requests.count({ where: { createdById: currentUser.id, status: 'reviewed' } }), + db.customers.count({ where: { createdById: currentUser.id } }), + db.transactions.count({ where: { createdById: currentUser.id } }), + db.stripe_events.count({ where: { createdById: currentUser.id } }), + db.transactions.findAll({ + where: { createdById: currentUser.id }, + include: [ + { model: db.businesses, as: 'business' }, + { model: db.customers, as: 'customer' }, + ], + order: [['createdAt', 'DESC']], + limit: 6, + }), + db.stripe_events.findAll({ + where: { createdById: currentUser.id }, + include: [ + { model: db.businesses, as: 'business' }, + ], + order: [['createdAt', 'DESC']], + limit: 6, + }), + ]); + + res.status(200).send({ + stats: { pending, sent, clicked, reviewed, customers, transactions, paymentEvents }, + requests, + recentTransactions, + recentEvents, + }); +})); + +router.post('/request', wrapAsync(async (req, res) => { + const currentUser = req.currentUser; + const body = req.body || {}; + const businessName = normalizeString(body.businessName); + const reviewLink = normalizeString(body.reviewLink); + const customerEmail = normalizeString(body.customerEmail).toLowerCase(); + const customerName = normalizeString(body.customerName); + const phone = normalizeString(body.phone); + const delayDays = Math.max(0, Math.min(Number(body.delayDays) || 0, 30)); + + requireField(businessName, 'Business name is required.'); + requireField(reviewLink, 'Review link is required.'); + requireField(customerEmail, 'Customer email is required.'); + + if (!EMAIL_PATTERN.test(customerEmail)) { + const error = new Error('Enter a valid customer email address.'); + error.code = 400; + throw error; + } + + validateUrl(reviewLink, 'Enter a valid Google, Yelp, Facebook, or review page URL.'); + + const scheduledFor = new Date(Date.now() + delayDays * 24 * 60 * 60 * 1000); + const transaction = await db.sequelize.transaction(); + + try { + const [business] = await db.businesses.findOrCreate({ + where: { name: businessName, createdById: currentUser.id }, + defaults: { + name: businessName, + google_review_link: reviewLink, + delay_days: delayDays, + email_subject_template: `How was your experience with ${businessName}?`, + email_body_template: buildEmailBody('{customerName}', businessName, '{reviewLink}'), + is_active: true, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + transaction, + }); + + await business.update({ + google_review_link: reviewLink, + delay_days: delayDays, + is_active: true, + updatedById: currentUser.id, + }, { transaction }); + + const [customer] = await db.customers.findOrCreate({ + where: { email: customerEmail, createdById: currentUser.id }, + defaults: { + email: customerEmail, + name: customerName || null, + phone: phone || null, + contact_status: 'active', + businessId: business.id, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + transaction, + }); + + await customer.update({ + name: customerName || customer.name, + phone: phone || customer.phone, + contact_status: customer.contact_status || 'active', + businessId: business.id, + updatedById: currentUser.id, + }, { transaction }); + + const emailSubject = `How was your experience with ${businessName}?`; + const reviewRequest = await db.review_requests.create({ + status: 'pending', + scheduled_for: scheduledFor, + email_subject: emailSubject, + email_body: buildEmailBody(customerName, businessName, reviewLink), + review_link: reviewLink, + tracking_token: crypto.randomBytes(18).toString('hex'), + businessId: business.id, + customerId: customer.id, + createdById: currentUser.id, + updatedById: currentUser.id, + }, { transaction }); + + await transaction.commit(); + + const createdRequest = await db.review_requests.findByPk(reviewRequest.id, { + include: [ + { model: db.businesses, as: 'business' }, + { model: db.customers, as: 'customer' }, + ], + }); + + res.status(201).send({ request: createdRequest }); + } catch (error) { + await transaction.rollback(); + throw error; + } +})); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/services/reviewflow.js b/backend/src/services/reviewflow.js new file mode 100644 index 0000000..209becc --- /dev/null +++ b/backend/src/services/reviewflow.js @@ -0,0 +1,697 @@ +const crypto = require('crypto'); +const db = require('../db/models'); + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const ZERO_DECIMAL_CURRENCIES = new Set([ + 'bif', + 'clp', + 'djf', + 'gnf', + 'jpy', + 'kmf', + 'krw', + 'mga', + 'pyg', + 'rwf', + 'ugx', + 'vnd', + 'vuv', + 'xaf', + 'xof', + 'xpf', +]); + +const PROVIDERS = { + stripe: { + label: 'Stripe', + accountField: 'stripe_account_reference', + connectedField: 'stripe_connected', + connectedAtField: 'stripe_connected_at', + tokenField: 'stripe_webhook_token', + paymentReferenceField: 'stripe_payment_reference', + customerReferenceField: 'stripe_customer_reference', + }, + square: { + label: 'Square', + accountField: 'square_account_reference', + connectedField: 'square_connected', + connectedAtField: 'square_connected_at', + tokenField: 'square_webhook_token', + paymentReferenceField: 'square_payment_reference', + customerReferenceField: 'square_customer_reference', + }, + paypal: { + label: 'PayPal', + accountField: 'paypal_merchant_reference', + connectedField: 'paypal_connected', + connectedAtField: 'paypal_connected_at', + tokenField: 'paypal_webhook_token', + paymentReferenceField: 'paypal_payment_reference', + customerReferenceField: 'paypal_customer_reference', + }, +}; + +function normalizeString(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +function normalizeEmail(value) { + return normalizeString(value).toLowerCase(); +} + +function httpError(message, code) { + const error = new Error(message); + error.code = code; + return error; +} + +function requireField(value, message) { + if (!normalizeString(value)) { + throw httpError(message, 400); + } +} + +function validateUrl(value, message) { + try { + const parsed = new URL(value); + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw httpError(message, 400); + } + } catch { + throw httpError(message, 400); + } +} + +function getProviderConfig(provider) { + const normalizedProvider = normalizeString(provider).toLowerCase(); + const config = PROVIDERS[normalizedProvider]; + + if (!config) { + throw httpError('Unsupported payment provider. Use stripe, square, or paypal.', 400); + } + + return { provider: normalizedProvider, ...config }; +} + +function generateWebhookToken() { + return crypto.randomBytes(24).toString('hex'); +} + +function getOwnerId(business) { + return business.ownerId || business.createdById || business.updatedById || null; +} + +function getRequestOrigin(req) { + const forwardedProto = normalizeString(req.headers['x-forwarded-proto']).split(',')[0]; + const proto = forwardedProto || req.protocol || 'https'; + const host = req.get('host'); + + return `${proto}://${host}`; +} + +function getWebhookUrl(req, business, provider) { + const config = getProviderConfig(provider); + const token = business[config.tokenField]; + + if (!token) { + return ''; + } + + return `${getRequestOrigin(req)}/api/reviewflow-webhooks/${config.provider}/${business.id}/${token}`; +} + +function getReviewLink(business) { + const platform = business.default_review_platform || 'google'; + + if (platform === 'custom') { + return business.custom_review_link || business.google_review_link || business.yelp_review_link || business.facebook_review_link || ''; + } + + if (platform === 'yelp') { + return business.yelp_review_link || business.google_review_link || business.facebook_review_link || business.custom_review_link || ''; + } + + if (platform === 'facebook') { + return business.facebook_review_link || business.google_review_link || business.yelp_review_link || business.custom_review_link || ''; + } + + return business.google_review_link || business.custom_review_link || business.yelp_review_link || business.facebook_review_link || ''; +} + +function buildEmailBody(customerName, businessName, reviewLink) { + const greetingName = customerName || 'there'; + + return [ + `Hi ${greetingName},`, + '', + `Thank you for choosing ${businessName}. We would love to hear about your experience.`, + '', + `Leave a review: ${reviewLink}`, + '', + 'Thank you,', + businessName, + ].join('\n'); +} + +function renderTemplate(template, replacements) { + if (!template) { + return ''; + } + + return Object.entries(replacements).reduce((output, [key, value]) => { + return output.replace(new RegExp(`{${key}}`, 'g'), value || ''); + }, template); +} + +function toDate(value) { + if (!value) { + return new Date(); + } + + if (typeof value === 'number') { + return new Date(value * 1000); + } + + const parsed = new Date(value); + + if (Number.isNaN(parsed.getTime())) { + return new Date(); + } + + return parsed; +} + +function minorUnitsToDecimal(amount, currency) { + if (amount === null || amount === undefined || amount === '') { + return null; + } + + const numericAmount = Number(amount); + + if (!Number.isFinite(numericAmount)) { + return null; + } + + const normalizedCurrency = normalizeString(currency).toLowerCase(); + const divisor = ZERO_DECIMAL_CURRENCIES.has(normalizedCurrency) ? 1 : 100; + + return (numericAmount / divisor).toFixed(2); +} + +function decimalAmount(value) { + if (value === null || value === undefined || value === '') { + return null; + } + + const numericAmount = Number(value); + + if (!Number.isFinite(numericAmount)) { + return null; + } + + return numericAmount.toFixed(2); +} + +function normalizeStripeEvent(payload) { + const payment = payload?.data?.object || payload?.object || payload || {}; + const charge = Array.isArray(payment?.charges?.data) ? payment.charges.data[0] : null; + const eventType = normalizeString(payload?.type || payment?.object || 'unknown'); + const currency = normalizeString(payment.currency || charge?.currency || '').toUpperCase(); + const amount = payment.amount_received ?? payment.amount_paid ?? payment.amount_total ?? payment.amount ?? charge?.amount; + const email = normalizeEmail( + payment.receipt_email || + payment.customer_email || + payment.customer_details?.email || + payment.billing_details?.email || + charge?.billing_details?.email || + charge?.receipt_email, + ); + const customerName = normalizeString( + payment.customer_details?.name || + payment.billing_details?.name || + charge?.billing_details?.name, + ); + + return { + eventReference: normalizeString(payload?.id || payment?.id), + providerEventType: eventType, + normalizedEventType: normalizeEventType('stripe', eventType, payment), + paymentReference: normalizeString(payment.payment_intent || payment.charge || payment.id || charge?.id), + amount: minorUnitsToDecimal(amount, currency), + currency, + email, + customerName, + phone: normalizeString(payment.customer_details?.phone || payment.billing_details?.phone || charge?.billing_details?.phone), + customerReference: normalizeString(payment.customer || charge?.customer), + description: normalizeString(payment.description || payment.statement_descriptor || charge?.description || eventType), + paidAt: toDate(payment.created || payload?.created), + isPaymentSuccess: [ + 'payment_intent.succeeded', + 'charge.succeeded', + 'checkout.session.completed', + 'invoice.payment_succeeded', + ].includes(eventType), + }; +} + +function normalizeSquareEvent(payload) { + const payment = payload?.data?.object?.payment || payload?.payment || payload?.data?.object || payload || {}; + const eventType = normalizeString(payload?.type || 'unknown'); + const money = payment.total_money || payment.amount_money || payment.approved_money || {}; + const currency = normalizeString(money.currency || '').toUpperCase(); + const paymentStatus = normalizeString(payment.status).toUpperCase(); + + return { + eventReference: normalizeString(payload?.event_id || payload?.id || payment.id), + providerEventType: eventType, + normalizedEventType: normalizeEventType('square', eventType, payment), + paymentReference: normalizeString(payment.id || payload?.event_id || payload?.id), + amount: minorUnitsToDecimal(money.amount, currency), + currency, + email: normalizeEmail(payment.buyer_email_address || payment.customer?.email_address), + customerName: normalizeString(payment.customer?.given_name || payment.customer?.family_name), + phone: normalizeString(payment.customer?.phone_number), + customerReference: normalizeString(payment.customer_id || payment.customer?.id), + description: normalizeString(payment.note || payment.receipt_url || eventType), + paidAt: toDate(payment.created_at || payment.updated_at || payload?.created_at), + isPaymentSuccess: eventType.startsWith('payment.') && paymentStatus === 'COMPLETED', + }; +} + +function normalizePaypalEvent(payload) { + const resource = payload?.resource || payload || {}; + const eventType = normalizeString(payload?.event_type || payload?.type || 'unknown'); + const amount = resource.amount || resource.seller_receivable_breakdown?.gross_amount || {}; + const payer = resource.payer || payload?.payer || {}; + + return { + eventReference: normalizeString(payload?.id || resource.id), + providerEventType: eventType, + normalizedEventType: normalizeEventType('paypal', eventType, resource), + paymentReference: normalizeString(resource.id || payload?.id), + amount: decimalAmount(amount.value), + currency: normalizeString(amount.currency_code || amount.currency || '').toUpperCase(), + email: normalizeEmail(payer.email_address || resource.email_address), + customerName: normalizeString([payer.name?.given_name, payer.name?.surname].filter(Boolean).join(' ')), + phone: normalizeString(payer.phone?.phone_number?.national_number), + customerReference: normalizeString(payer.payer_id || resource.payer_id), + description: normalizeString(resource.description || resource.invoice_id || resource.custom_id || eventType), + paidAt: toDate(resource.create_time || resource.update_time || payload?.create_time), + isPaymentSuccess: [ + 'PAYMENT.CAPTURE.COMPLETED', + 'PAYMENT.SALE.COMPLETED', + 'CHECKOUT.ORDER.COMPLETED', + ].includes(eventType), + }; +} + +function normalizeEventType(provider, providerEventType, payment) { + const eventType = normalizeString(providerEventType); + const status = normalizeString(payment?.status).toLowerCase(); + + if (provider === 'stripe') { + if (eventType === 'charge.succeeded') return 'charge_succeeded'; + if (eventType === 'payment_intent.succeeded' || eventType === 'checkout.session.completed' || eventType === 'invoice.payment_succeeded') return 'payment_intent_succeeded'; + if (eventType === 'charge.refunded') return 'charge_refunded'; + if (eventType === 'charge.failed' || eventType === 'payment_intent.payment_failed') return 'charge_failed'; + } + + if (provider === 'square') { + if (eventType.startsWith('refund.')) return 'charge_refunded'; + if (status === 'failed' || status === 'canceled') return 'charge_failed'; + if (status === 'completed') return 'payment_intent_succeeded'; + } + + if (provider === 'paypal') { + if (eventType.includes('REFUND')) return 'charge_refunded'; + if (eventType.includes('DENIED') || eventType.includes('FAILED')) return 'charge_failed'; + if (eventType.includes('COMPLETED')) return 'payment_intent_succeeded'; + } + + return 'unknown'; +} + +function normalizePaymentEvent(provider, payload) { + if (provider === 'stripe') { + return normalizeStripeEvent(payload); + } + + if (provider === 'square') { + return normalizeSquareEvent(payload); + } + + return normalizePaypalEvent(payload); +} + +function serializeBusiness(req, business) { + return { + id: business.id, + name: business.name, + google_review_link: business.google_review_link, + yelp_review_link: business.yelp_review_link, + facebook_review_link: business.facebook_review_link, + custom_review_link: business.custom_review_link, + default_review_platform: business.default_review_platform, + delay_days: business.delay_days, + providers: Object.keys(PROVIDERS).map((providerKey) => { + const config = getProviderConfig(providerKey); + const token = business[config.tokenField] || ''; + + return { + key: providerKey, + label: config.label, + connected: Boolean(business[config.connectedField]), + connected_at: business[config.connectedAtField] || null, + account_reference: business[config.accountField] || '', + webhook_token: token, + webhook_token_last4: token ? token.slice(-4) : '', + webhook_url: getWebhookUrl(req, business, providerKey), + }; + }), + }; +} + +async function listConnectorBusinesses(currentUser, req) { + const businesses = await db.businesses.findAll({ + where: { createdById: currentUser.id }, + order: [['updatedAt', 'DESC']], + limit: 50, + }); + + return businesses.map((business) => serializeBusiness(req, business)); +} + +async function connectProvider(currentUser, body, req) { + const config = getProviderConfig(body.provider); + const businessId = normalizeString(body.businessId); + const businessName = normalizeString(body.businessName); + const reviewLink = normalizeString(body.reviewLink); + const accountReference = normalizeString(body.accountReference); + const delayDays = Math.max(0, Math.min(Number(body.delayDays) || 0, 30)); + let business; + + if (businessId) { + business = await db.businesses.findOne({ where: { id: businessId, createdById: currentUser.id } }); + + if (!business) { + throw httpError('Business not found for this account.', 404); + } + } else { + requireField(businessName, 'Business name is required.'); + business = await db.businesses.findOne({ where: { name: businessName, createdById: currentUser.id } }); + } + + if (reviewLink) { + validateUrl(reviewLink, 'Enter a valid review page URL before connecting a webhook.'); + } + + if (!business) { + business = await db.businesses.create({ + name: businessName, + google_review_link: reviewLink || null, + delay_days: delayDays, + email_subject_template: `How was your experience with ${businessName}?`, + email_body_template: buildEmailBody('{customerName}', businessName, '{reviewLink}'), + is_active: true, + createdById: currentUser.id, + updatedById: currentUser.id, + ownerId: currentUser.id, + }); + } + + const updates = { + is_active: true, + delay_days: delayDays, + updatedById: currentUser.id, + [config.connectedField]: true, + [config.connectedAtField]: new Date(), + [config.accountField]: accountReference || business[config.accountField], + [config.tokenField]: business[config.tokenField] || generateWebhookToken(), + }; + + if (businessName) { + updates.name = businessName; + } + + if (reviewLink) { + updates.google_review_link = reviewLink; + } + + await business.update(updates); + + const refreshedBusiness = await db.businesses.findByPk(business.id); + return serializeBusiness(req, refreshedBusiness); +} + +async function rotateWebhookToken(currentUser, businessId, provider, req) { + const config = getProviderConfig(provider); + const business = await db.businesses.findOne({ where: { id: businessId, createdById: currentUser.id } }); + + if (!business) { + throw httpError('Business not found for this account.', 404); + } + + await business.update({ + [config.tokenField]: generateWebhookToken(), + updatedById: currentUser.id, + }); + + const refreshedBusiness = await db.businesses.findByPk(business.id); + return serializeBusiness(req, refreshedBusiness); +} + +async function createCustomerFromPayment(payment, business, transaction) { + const ownerId = getOwnerId(business); + const email = normalizeEmail(payment.email); + + if (!EMAIL_PATTERN.test(email)) { + return null; + } + + const config = getProviderConfig(payment.provider); + const [customer] = await db.customers.findOrCreate({ + where: { email, createdById: ownerId }, + defaults: { + email, + name: payment.customerName || null, + phone: payment.phone || null, + contact_status: 'active', + last_transaction_at: payment.paidAt, + businessId: business.id, + [config.customerReferenceField]: payment.customerReference || null, + createdById: ownerId, + updatedById: ownerId, + }, + transaction, + }); + + await customer.update({ + name: payment.customerName || customer.name, + phone: payment.phone || customer.phone, + contact_status: customer.contact_status || 'active', + last_transaction_at: payment.paidAt, + businessId: business.id, + [config.customerReferenceField]: payment.customerReference || customer[config.customerReferenceField], + updatedById: ownerId, + }, { transaction }); + + return customer; +} + +async function createTransactionFromPayment(payment, business, customer, transaction) { + const ownerId = getOwnerId(business); + const config = getProviderConfig(payment.provider); + + if (payment.paymentReference) { + const existingTransaction = await db.transactions.findOne({ + where: { + businessId: business.id, + [config.paymentReferenceField]: payment.paymentReference, + }, + transaction, + }); + + if (existingTransaction) { + return { transactionRecord: existingTransaction, duplicate: true }; + } + } + + const transactionRecord = await db.transactions.create({ + payment_provider: payment.provider, + stripe_payment_reference: payment.provider === 'stripe' ? payment.paymentReference || null : null, + square_payment_reference: payment.provider === 'square' ? payment.paymentReference || null : null, + paypal_payment_reference: payment.provider === 'paypal' ? payment.paymentReference || null : null, + provider_event_reference: payment.eventReference || null, + amount: payment.amount, + currency: payment.currency || null, + payment_status: payment.isPaymentSuccess ? 'succeeded' : 'pending', + paid_at: payment.paidAt, + description: payment.description || null, + receipt_email: payment.email || null, + businessId: business.id, + customerId: customer?.id || null, + createdById: ownerId, + updatedById: ownerId, + }, { transaction }); + + return { transactionRecord, duplicate: false }; +} + +async function createReviewRequestFromPayment(payment, business, customer, transactionRecord, transaction) { + const ownerId = getOwnerId(business); + const reviewLink = getReviewLink(business); + + if (!payment.isPaymentSuccess) { + return null; + } + + if (!customer) { + return null; + } + + if (!reviewLink) { + return null; + } + + const delayDays = Math.max(0, Number(business.delay_days) || 0); + const scheduledFor = new Date(Date.now() + delayDays * 24 * 60 * 60 * 1000); + const businessName = business.name || 'our business'; + const customerName = customer.name || payment.customerName || 'there'; + const replacements = { businessName, customerName, reviewLink }; + const emailSubject = renderTemplate( + business.email_subject_template || `How was your experience with {businessName}?`, + replacements, + ) || `How was your experience with ${businessName}?`; + const emailBody = renderTemplate(business.email_body_template, replacements) || buildEmailBody(customerName, businessName, reviewLink); + + return db.review_requests.create({ + status: 'pending', + scheduled_for: scheduledFor, + email_subject: emailSubject, + email_body: emailBody, + review_link: reviewLink, + tracking_token: crypto.randomBytes(18).toString('hex'), + businessId: business.id, + customerId: customer.id, + transactionId: transactionRecord.id, + createdById: ownerId, + updatedById: ownerId, + }, { transaction }); +} + +async function processPaymentWebhook(providerName, businessId, secretToken, payload) { + const { provider, ...config } = getProviderConfig(providerName); + const business = await db.businesses.findByPk(businessId); + + if (!business) { + throw httpError('Webhook business was not found.', 404); + } + + if (!business[config.tokenField] || business[config.tokenField] !== secretToken) { + throw httpError('Webhook token is invalid for this business and provider.', 403); + } + + const payment = { + provider, + ...normalizePaymentEvent(provider, payload || {}), + }; + const ownerId = getOwnerId(business); + const existingEvent = payment.eventReference ? await db.stripe_events.findOne({ + where: { + businessId: business.id, + provider, + stripe_event_reference: payment.eventReference, + }, + }) : null; + + if (existingEvent?.processed) { + return { + duplicate: true, + processed: true, + eventId: existingEvent.id, + message: 'Duplicate webhook event ignored because it was already processed.', + }; + } + + const eventLog = existingEvent || await db.stripe_events.create({ + businessId: business.id, + stripe_event_reference: payment.eventReference || null, + provider, + provider_event_type: payment.providerEventType, + event_type: payment.normalizedEventType, + received_at: new Date(), + processed: false, + payload_json: JSON.stringify(payload || {}), + createdById: ownerId, + updatedById: ownerId, + }); + + const transaction = await db.sequelize.transaction(); + + try { + await business.update({ + [config.connectedField]: true, + [config.connectedAtField]: business[config.connectedAtField] || new Date(), + }, { transaction }); + + const customer = await createCustomerFromPayment(payment, business, transaction); + const { transactionRecord, duplicate } = await createTransactionFromPayment(payment, business, customer, transaction); + const reviewRequest = duplicate ? null : await createReviewRequestFromPayment( + payment, + business, + customer, + transactionRecord, + transaction, + ); + let processingError = null; + + if (payment.isPaymentSuccess && !customer) { + processingError = 'Payment was saved, but no customer email was present, so no review request was queued.'; + } + + if (payment.isPaymentSuccess && customer && !reviewRequest && !duplicate) { + processingError = 'Payment was saved, but the business has no review link, so no review request was queued.'; + } + + await eventLog.update({ + processed: true, + processed_at: new Date(), + processing_error: processingError, + updatedById: ownerId, + }, { transaction }); + + await transaction.commit(); + + return { + duplicate, + processed: true, + provider, + eventId: eventLog.id, + transactionId: transactionRecord.id, + customerId: customer?.id || null, + reviewRequestId: reviewRequest?.id || null, + message: processingError || (reviewRequest ? 'Payment webhook processed and review request queued.' : 'Payment webhook processed.'), + }; + } catch (error) { + await transaction.rollback(); + await eventLog.update({ + processed: false, + processing_error: error.message, + updatedById: ownerId, + }); + throw error; + } +} + +module.exports = { + PROVIDERS, + buildEmailBody, + connectProvider, + generateWebhookToken, + getProviderConfig, + getWebhookUrl, + listConnectorBusinesses, + processPaymentWebhook, + rotateWebhookToken, + serializeBusiness, +}; diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx index ab64778..9d4f84d 100644 --- a/frontend/src/components/AsideMenuLayer.tsx +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -39,7 +39,7 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props >
- ReviewFlow + Review Flow
diff --git a/frontend/src/components/Customers/configureCustomersCols.tsx b/frontend/src/components/Customers/configureCustomersCols.tsx index 0d07b21..10011f5 100644 --- a/frontend/src/components/Customers/configureCustomersCols.tsx +++ b/frontend/src/components/Customers/configureCustomersCols.tsx @@ -152,7 +152,7 @@ export const loadColumns = async ( type: 'dateTime', valueGetter: (params: GridValueGetterParams) => - new Date(params.row.last_transaction_at), + params.row.last_transaction_at ? new Date(params.row.last_transaction_at) : null, }, diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..fcbd9b9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx b/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx new file mode 100644 index 0000000..eab9f67 --- /dev/null +++ b/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx @@ -0,0 +1,735 @@ +import { + mdiAlertCircleOutline, + mdiCheckCircleOutline, + mdiContentCopy, + mdiCreditCardOutline, + mdiRefresh, + mdiWebhook, +} from '@mdi/js'; +import axios from 'axios'; +import React, { FormEvent, useEffect, useMemo, useState } from 'react'; +import BaseButton from '../BaseButton'; +import CardBox from '../CardBox'; +import FormField from '../FormField'; + +export interface ProviderConnector { + key: 'stripe' | 'square' | 'paypal' | string; + label: string; + connected: boolean; + connected_at?: string | null; + account_reference?: string; + webhook_token?: string; + webhook_token_last4?: string; + webhook_url?: string; +} + +export interface ConnectorBusiness { + id: string; + name?: string; + google_review_link?: string; + delay_days?: number; + providers: ProviderConnector[]; +} + +export interface ConnectorFormValues { + provider: string; + businessName: string; + reviewLink: string; + delayDays: string; + accountReference: string; +} + +interface PaymentProviderConnectorsProps { + className?: string; + eyebrow?: string; + title?: string; + description?: string; + onConnected?: ( + business: ConnectorBusiness, + connectorForm: ConnectorFormValues, + ) => void | Promise; +} + +const connectorDefaults: ConnectorFormValues = { + provider: 'stripe', + businessName: 'Review Flow Studio', + reviewLink: 'https://g.page/r/example/review', + delayDays: '7', + accountReference: '', +}; + +const providerOptions = [ + { + key: 'stripe', + label: 'Stripe', + description: 'Connect card and checkout payments from Stripe.', + }, + { + key: 'paypal', + label: 'PayPal', + description: 'Connect completed PayPal captures and sales.', + }, + { + key: 'square', + label: 'Square', + description: 'Connect Square payment notifications.', + }, +]; + +const providerInstructions: Record = { + stripe: [ + 'Stripe Dashboard → Developers → Webhooks → Add endpoint.', + 'Paste this Review Flow webhook URL as the endpoint URL.', + 'Send checkout.session.completed, payment_intent.succeeded, and charge.succeeded events.', + ], + square: [ + 'Square Developer Dashboard → Webhooks → Create subscription.', + 'Paste this Review Flow webhook URL as the notification URL.', + 'Send payment.created and payment.updated events so completed payments queue reviews.', + ], + paypal: [ + 'PayPal Developer Dashboard → Webhooks → Add webhook.', + 'Paste this Review Flow webhook URL as the webhook URL.', + 'Send PAYMENT.CAPTURE.COMPLETED or PAYMENT.SALE.COMPLETED events.', + ], +}; + +const providerSetupDetails: Record< + string, + { + dashboardPath: string; + requiredEvents: string[]; + steps: string[]; + testTip: string; + } +> = { + stripe: { + dashboardPath: + 'Stripe Dashboard → Developers → Webhooks → Add endpoint or Create event destination', + requiredEvents: [ + 'checkout.session.completed', + 'payment_intent.succeeded', + 'charge.succeeded', + ], + steps: [ + 'Select Stripe in Review Flow, enter the business name, review link, delay days, and click Connect Stripe.', + 'Copy the generated Review Flow webhook URL from the connected account card.', + 'In Stripe, create a new webhook endpoint and paste the copied URL into the endpoint URL field.', + 'Choose your account events unless you are intentionally configuring a Stripe Connect platform flow.', + 'Add the required successful payment event types listed below, then save the endpoint.', + 'Send a Stripe test webhook or make a test payment, then return here and click Refresh connectors.', + ], + testTip: + 'A successful Stripe delivery should show a 2xx response and create a payment event in Review Flow.', + }, + square: { + dashboardPath: + 'Square Developer Console → Application → Webhooks → Subscriptions → Add subscription', + requiredEvents: ['payment.created', 'payment.updated'], + steps: [ + 'Select Square in Review Flow, enter the business name, review link, delay days, and click Connect Square.', + 'Copy the generated Review Flow webhook URL from the connected account card.', + 'In Square, open your application, create a webhook subscription, and paste the copied URL as the notification URL.', + 'Select the payment events listed below so completed payments can be converted into review requests.', + 'Save the subscription and keep any Square signature details private for future verification hardening.', + 'Use a Square test payment or webhook test delivery, then return here and click Refresh connectors.', + ], + testTip: + 'Square payments queue reviews only when the payment status is completed and a customer email is available.', + }, + paypal: { + dashboardPath: + 'PayPal Developer Dashboard → Apps & Credentials → REST app → Webhooks → Add webhook', + requiredEvents: [ + 'PAYMENT.CAPTURE.COMPLETED', + 'PAYMENT.SALE.COMPLETED', + 'CHECKOUT.ORDER.COMPLETED', + ], + steps: [ + 'Select PayPal in Review Flow, enter the business name, review link, delay days, and click Connect PayPal.', + 'Copy the generated Review Flow webhook URL from the connected account card.', + 'In PayPal, open the REST app that receives your payments and add a new webhook.', + 'Paste the copied URL into the webhook URL field and subscribe to the completed payment events listed below.', + 'Save the webhook, then confirm it is attached to the same PayPal app used by your checkout flow.', + 'Run a sandbox checkout or webhook simulator event, then return here and click Refresh connectors.', + ], + testTip: + 'A completed PayPal capture, sale, or checkout order should appear as a payment event before a review request is queued.', + }, +}; + +const providerGradient: Record = { + stripe: 'from-indigo-600 to-violet-600', + square: 'from-emerald-600 to-teal-600', + paypal: 'from-sky-600 to-blue-700', +}; + +function formatDate(value?: string | null) { + if (!value) return 'Not scheduled'; + + return new Intl.DateTimeFormat('en', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }).format(new Date(value)); +} + +function hasAuthToken() { + return ( + typeof window !== 'undefined' && Boolean(localStorage.getItem('token')) + ); +} + +function isUnauthorizedError(error: unknown) { + return axios.isAxiosError(error) && error.response?.status === 401; +} + +export default function PaymentProviderConnectors({ + className = '', + eyebrow = 'Payment webhooks', + title = 'Connect Stripe, PayPal, and Square', + description = 'Connect each payment provider once. Successful payment webhooks can create customers, save transactions, and queue review requests automatically.', + onConnected, +}: PaymentProviderConnectorsProps) { + const [connectorForm, setConnectorForm] = + useState(connectorDefaults); + const [connectors, setConnectors] = useState([]); + const [isConnectorLoading, setIsConnectorLoading] = useState(true); + const [isConnectorSubmitting, setIsConnectorSubmitting] = useState(false); + const [connectorMessage, setConnectorMessage] = useState(''); + const [error, setError] = useState(''); + const [copiedUrl, setCopiedUrl] = useState(''); + const [isClientReady, setIsClientReady] = useState(false); + + const selectedProvider = + providerOptions.find( + (provider) => provider.key === connectorForm.provider, + ) || providerOptions[0]; + + const connectorPreviewDate = useMemo(() => { + if (!isClientReady) return 'after the selected delay'; + + const days = Math.max(0, Number(connectorForm.delayDays) || 0); + return formatDate( + new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString(), + ); + }, [connectorForm.delayDays, isClientReady]); + + const providerSummary = useMemo(() => { + const providers = connectors.flatMap( + (business) => business.providers || [], + ); + const connectedCount = providers.filter( + (provider) => provider.connected, + ).length; + + return { + connectedCount, + totalCount: providers.length || providerOptions.length, + }; + }, [connectors]); + + const updateConnectorForm = ( + key: keyof ConnectorFormValues, + value: string, + ) => { + setConnectorForm((current) => ({ ...current, [key]: value })); + }; + + const loadConnectors = async () => { + setIsConnectorLoading(true); + try { + const response = await axios.get('/reviewflow/connectors'); + setConnectors(response.data.businesses || []); + setError(''); + } catch (requestError) { + if (!isUnauthorizedError(requestError)) { + console.error( + 'Failed to load payment webhook connectors:', + requestError, + ); + setError( + 'Could not load your payment connectors. Please refresh or try again.', + ); + } + } finally { + setIsConnectorLoading(false); + } + }; + + useEffect(() => { + setIsClientReady(true); + + if (!hasAuthToken()) { + setIsConnectorLoading(false); + return; + } + + loadConnectors(); + }, []); + + const handleConnectorSubmit = async (event: FormEvent) => { + event.preventDefault(); + setIsConnectorSubmitting(true); + setConnectorMessage(''); + setError(''); + + try { + const response = await axios.post('/reviewflow/connectors', { + ...connectorForm, + delayDays: Number(connectorForm.delayDays), + }); + const business = response.data.business as ConnectorBusiness; + setConnectorMessage( + `${selectedProvider.label} is connected for ${business.name}. Copy the secure webhook URL below into your ${selectedProvider.label} dashboard.`, + ); + + await loadConnectors(); + + if (onConnected) { + try { + await onConnected(business, connectorForm); + } catch (refreshError) { + console.error( + 'Payment connector post-connect refresh failed:', + refreshError, + ); + setError( + 'The connection was created, but the dashboard refresh failed. Please refresh the page to see the latest data.', + ); + } + } + } catch (requestError) { + console.error( + 'Failed to connect payment webhook provider:', + requestError, + ); + if (axios.isAxiosError(requestError) && requestError.response?.data) { + setError(String(requestError.response.data)); + } else { + setError( + 'Could not connect this payment provider. Please check the fields and try again.', + ); + } + } finally { + setIsConnectorSubmitting(false); + } + }; + + const rotateWebhookToken = async (businessId: string, provider: string) => { + setConnectorMessage(''); + setError(''); + + try { + await axios.post( + `/reviewflow/connectors/${businessId}/${provider}/rotate`, + ); + setConnectorMessage( + `${provider.toUpperCase()} webhook token rotated. Update the webhook URL inside ${provider.toUpperCase()} before sending more live payments.`, + ); + await loadConnectors(); + } catch (requestError) { + console.error('Failed to rotate payment webhook token:', requestError); + setError('Could not rotate the webhook token. Please try again.'); + } + }; + + const copyWebhookUrl = async (url?: string) => { + if (!url) return; + + try { + await navigator.clipboard.writeText(url); + setCopiedUrl(url); + setConnectorMessage( + 'Webhook URL copied. Paste it into the matching payment provider dashboard.', + ); + window.setTimeout(() => setCopiedUrl(''), 2500); + } catch (requestError) { + console.error('Failed to copy webhook URL:', requestError); + setError( + 'Could not copy the webhook URL. You can still select and copy it manually.', + ); + } + }; + + return ( + +
+
+

+ {eyebrow} +

+

+ {title} +

+

+ {description} +

+
+
+
+ + Secure connection note +
+ Payment providers must POST to a public webhook URL. These endpoints + are protected by the long secret token embedded in each generated URL. + Rotate a URL if it is ever exposed. +
+
+ + {connectorMessage && ( +
+ Connection update. {connectorMessage} +
+ )} + {error && ( +
+ {error} +
+ )} + +
+ {providerOptions.map((provider) => { + const isSelected = connectorForm.provider === provider.key; + + return ( + + ); + })} +
+ +
+ + + + updateConnectorForm('businessName', event.target.value) + } + placeholder='Business name' + /> + + updateConnectorForm('accountReference', event.target.value) + } + placeholder='Optional account / merchant ID' + /> + + + + updateConnectorForm('reviewLink', event.target.value) + } + placeholder='https://g.page/.../review' + /> + + updateConnectorForm('delayDays', event.target.value) + } + placeholder='Delay days' + /> + +
+ + +
+
+ +
+
+

+ Installation guide +

+

+ How to install payment webhooks +

+

+ First connect a provider above to generate the secure Review Flow + webhook URL. Then follow the matching provider instructions below + and use the copied URL in that provider dashboard. +

+
+ +
+ {providerOptions.map((provider) => { + const setup = providerSetupDetails[provider.key]; + + return ( +
+
+

+ {provider.label} +

+
Webhook setup
+
+
+
+

+ Dashboard path +

+

+ {setup.dashboardPath} +

+
+ +
+

+ Install steps +

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

+ Events to enable +

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

+ Connected accounts +

+

+ {providerSummary.connectedCount} of {providerSummary.totalCount}{' '} + provider slots connected +

+
+
+ + {isConnectorLoading ? ( +
+ Loading payment connectors... +
+ ) : connectors.length === 0 ? ( +
+ +

+ No payment providers connected yet +

+

+ Choose Stripe, PayPal, or Square above to generate your first secure + webhook URL. +

+
+ ) : ( +
+ {connectors.map((business) => ( +
+
+
+

+ {business.name} +

+

+ Default review delay: {business.delay_days ?? 0} days +

+
+ +
+
+ {business.providers.map((provider) => ( +
+
+
+
+

+ {provider.label} +

+
+ Webhook receiver +
+
+ + {provider.connected ? 'Connected' : 'Not connected'} + +
+
+
+
+

+ Webhook URL +

+ + {provider.webhook_url || + 'Connect this provider to reveal its secure webhook endpoint.'} + +
+
+ copyWebhookUrl(provider.webhook_url)} + /> + + rotateWebhookToken(business.id, provider.key) + } + /> +
+
+

+ Setup steps +

+
    + {(providerInstructions[provider.key] || []).map( + (instruction) => ( +
  1. {instruction}
  2. + ), + )} +
+ {provider.webhook_token_last4 && ( +

+ Secret token ends in: **** + {provider.webhook_token_last4} +

+ )} +
+
+
+ ))} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/Stripe_events/configureStripe_eventsCols.tsx b/frontend/src/components/Stripe_events/configureStripe_eventsCols.tsx index beb1c13..c391e45 100644 --- a/frontend/src/components/Stripe_events/configureStripe_eventsCols.tsx +++ b/frontend/src/components/Stripe_events/configureStripe_eventsCols.tsx @@ -63,9 +63,39 @@ export const loadColumns = async ( }, + { + field: 'provider', + headerName: 'Provider', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + + editable: hasUpdatePermission, + + + }, + + { + field: 'provider_event_type', + headerName: 'ProviderEventType', + flex: 1, + minWidth: 160, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + + editable: hasUpdatePermission, + + + }, + { field: 'stripe_event_reference', - headerName: 'StripeEventReference', + headerName: 'EventReference', flex: 1, minWidth: 120, filterable: false, diff --git a/frontend/src/components/Transactions/configureTransactionsCols.tsx b/frontend/src/components/Transactions/configureTransactionsCols.tsx index 4dbb71c..dfa67db 100644 --- a/frontend/src/components/Transactions/configureTransactionsCols.tsx +++ b/frontend/src/components/Transactions/configureTransactionsCols.tsx @@ -63,9 +63,24 @@ export const loadColumns = async ( }, + { + field: 'payment_provider', + headerName: 'Provider', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + + editable: hasUpdatePermission, + + + }, + { field: 'stripe_payment_reference', - headerName: 'StripePaymentReference', + headerName: 'PaymentReference', flex: 1, minWidth: 120, filterable: false, diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 06a30e2..6ea41b5 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -1,5 +1,5 @@ import * as icon from '@mdi/js'; -import { MenuAsideItem } from './interfaces' +import { MenuAsideItem } from './interfaces'; const menuAside: MenuAsideItem[] = [ { @@ -7,14 +7,20 @@ const menuAside: MenuAsideItem[] = [ icon: icon.mdiViewDashboardOutline, label: 'Dashboard', }, - + + { + href: '/reviewflow', + icon: icon.mdiStarOutline, + label: 'Review Flow', + }, + { href: '/users/users-list', label: 'Users', // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: icon.mdiAccountGroup ?? icon.mdiTable, - permissions: 'READ_USERS' + permissions: 'READ_USERS', }, { href: '/roles/roles-list', @@ -22,7 +28,7 @@ const menuAside: MenuAsideItem[] = [ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable, - permissions: 'READ_ROLES' + permissions: 'READ_ROLES', }, { href: '/permissions/permissions-list', @@ -30,63 +36,84 @@ const menuAside: MenuAsideItem[] = [ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: icon.mdiShieldAccountOutline ?? icon.mdiTable, - permissions: 'READ_PERMISSIONS' + permissions: 'READ_PERMISSIONS', }, { href: '/businesses/businesses-list', label: 'Businesses', // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - icon: 'mdiStore' in icon ? icon['mdiStore' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_BUSINESSES' + icon: + 'mdiStore' in icon + ? icon['mdiStore' as keyof typeof icon] + : (icon.mdiTable ?? icon.mdiTable), + permissions: 'READ_BUSINESSES', }, { href: '/customers/customers-list', label: 'Customers', // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - icon: 'mdiAccountMultiple' in icon ? icon['mdiAccountMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_CUSTOMERS' + icon: + 'mdiAccountMultiple' in icon + ? icon['mdiAccountMultiple' as keyof typeof icon] + : (icon.mdiTable ?? icon.mdiTable), + permissions: 'READ_CUSTOMERS', }, { href: '/transactions/transactions-list', label: 'Transactions', // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - icon: 'mdiCreditCardOutline' in icon ? icon['mdiCreditCardOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_TRANSACTIONS' + icon: + 'mdiCreditCardOutline' in icon + ? icon['mdiCreditCardOutline' as keyof typeof icon] + : (icon.mdiTable ?? icon.mdiTable), + permissions: 'READ_TRANSACTIONS', }, { href: '/review_requests/review_requests-list', label: 'Review requests', // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - icon: 'mdiEmailFastOutline' in icon ? icon['mdiEmailFastOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_REVIEW_REQUESTS' + icon: + 'mdiEmailFastOutline' in icon + ? icon['mdiEmailFastOutline' as keyof typeof icon] + : (icon.mdiTable ?? icon.mdiTable), + permissions: 'READ_REVIEW_REQUESTS', }, { href: '/stripe_events/stripe_events-list', - label: 'Stripe events', + label: 'Payment events', // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - icon: 'mdiWebhook' in icon ? icon['mdiWebhook' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_STRIPE_EVENTS' + icon: + 'mdiWebhook' in icon + ? icon['mdiWebhook' as keyof typeof icon] + : (icon.mdiTable ?? icon.mdiTable), + permissions: 'READ_STRIPE_EVENTS', }, { href: '/email_delivery_logs/email_delivery_logs-list', label: 'Email delivery logs', // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - icon: 'mdiEmailCheckOutline' in icon ? icon['mdiEmailCheckOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_EMAIL_DELIVERY_LOGS' + icon: + 'mdiEmailCheckOutline' in icon + ? icon['mdiEmailCheckOutline' as keyof typeof icon] + : (icon.mdiTable ?? icon.mdiTable), + permissions: 'READ_EMAIL_DELIVERY_LOGS', }, { href: '/cron_runs/cron_runs-list', label: 'Cron runs', // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - icon: 'mdiClockOutline' in icon ? icon['mdiClockOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_CRON_RUNS' + icon: + 'mdiClockOutline' in icon + ? icon['mdiClockOutline' as keyof typeof icon] + : (icon.mdiTable ?? icon.mdiTable), + permissions: 'READ_CRON_RUNS', }, { href: '/profile', @@ -94,14 +121,13 @@ const menuAside: MenuAsideItem[] = [ icon: icon.mdiAccountCircle, }, - { href: '/api-docs', target: '_blank', label: 'Swagger API', icon: icon.mdiFileCode, - permissions: 'READ_API_DOCS' + permissions: 'READ_API_DOCS', }, -] +]; -export default menuAside +export default menuAside; diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index 8b4a58f..a06648c 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -149,7 +149,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { setStepsEnabled(false); }; - const title = 'ReviewFlow' + const title = 'Review Flow' const description = "Automate review-request emails after Stripe payments with templates, scheduling, and dashboard analytics." const url = "https://flatlogic.com/" const image = "https://project-screens.s3.amazonaws.com/screenshots/40346/app-hero-20260629-021915.png" diff --git a/frontend/src/pages/connect.tsx b/frontend/src/pages/connect.tsx new file mode 100644 index 0000000..4235d96 --- /dev/null +++ b/frontend/src/pages/connect.tsx @@ -0,0 +1,69 @@ +import { mdiConnection, mdiOpenInNew, mdiWebhook } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +import BaseButton from '../components/BaseButton'; +import PaymentProviderConnectors from '../components/ReviewFlow/PaymentProviderConnectors'; +import SectionMain from '../components/SectionMain'; +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../config'; +import LayoutAuthenticated from '../layouts/Authenticated'; + +export default function ConnectPage() { + return ( + <> + + {getPageTitle('Connect')} + + + + + + +
+
+
+

+ Payment provider setup +

+

+ Connect Stripe, PayPal, and Square. +

+

+ Use this page to generate secure webhook URLs for each provider. + Once connected, successful payments can flow into Review Flow and + automatically create customers, transactions, and review + requests. +

+
+
+
+ +
+

How connection works

+

+ Pick a provider, enter the business review settings, then copy + the generated webhook URL into that provider dashboard. You can + rotate URLs anytime if a secret is exposed. +

+
+
+
+ + +
+ + ); +} + +ConnectPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index de6b1fd..aed2939 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,161 +1,170 @@ - -import React, { useEffect, useState } from 'react'; -import type { ReactElement } from 'react'; +import { mdiArrowRight, mdiChartTimelineVariant, mdiCheckCircleOutline, mdiLogin, mdiShieldCheckOutline, mdiStarCircleOutline } from '@mdi/js'; import Head from 'next/head'; import Link from 'next/link'; +import React, { ReactElement } from 'react'; import BaseButton from '../components/BaseButton'; import CardBox from '../components/CardBox'; -import SectionFullScreen from '../components/SectionFullScreen'; import LayoutGuest from '../layouts/Guest'; -import BaseDivider from '../components/BaseDivider'; -import BaseButtons from '../components/BaseButtons'; import { getPageTitle } from '../config'; -import { useAppSelector } from '../stores/hooks'; -import CardBoxComponentTitle from "../components/CardBoxComponentTitle"; -import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; +const metrics = [ + ['7 days', 'default review delay'], + ['3 sources', 'Stripe, Square, PayPal'], + ['4 states', 'pending, sent, clicked, reviewed'], +]; + +const steps = [ + ['Capture', 'Receive Stripe, Square, or PayPal payment webhooks as soon as checkout happens.'], + ['Schedule', 'Create the customer, transaction, and review request automatically with your preferred delay.'], + ['Track', 'Follow pending, sent, clicked, and reviewed requests from one workspace.'], +]; + +const features = [ + 'Business review links and templates', + 'Webhook-created customers and transactions', + 'Readable queue with message preview', + 'Admin CRUD and API docs still available', +]; export default function Starter() { - const [illustrationImage, setIllustrationImage] = useState({ - src: undefined, - photographer: undefined, - photographer_url: undefined, - }) - const [illustrationVideo, setIllustrationVideo] = useState({video_files: []}) - const [contentType, setContentType] = useState('video'); - const [contentPosition, setContentPosition] = useState('left'); - const textColor = useAppSelector((state) => state.style.linkColor); - - const title = 'ReviewFlow' - - // Fetch Pexels image/video - useEffect(() => { - async function fetchData() { - const image = await getPexelsImage(); - const video = await getPexelsVideo(); - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); - - const imageBlock = (image) => ( - - ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- - -
) - } - }; - return ( -
+
- {getPageTitle('Starter Page')} + {getPageTitle('Review Flow')} + - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

-
- - - - - -
+
+
+ + + ★ + + Review Flow + +
-
- -
-

© 2026 {title}. All rights reserved

- - Privacy Policy - -
+ +
+
+
+
+
+
+ + Review automation for modern local businesses +
+

+ Ask at the perfect moment. Earn more five-star reviews. +

+

+ Review Flow turns Stripe, Square, and PayPal payment webhooks into scheduled review requests with a clean queue, message preview, and admin controls already wired into your app. +

+
+ + +
+
+ {metrics.map(([value, label]) => ( +
+

{value}

+

{label}

+
+ ))} +
+
+ + +
+
+
+

Live workflow

+

Review request queued

+
+ + pending + +
+
+
+
+

Customer

+

Maya Chen

+

maya@example.com

+
+
+
+

Scheduled

+

+7 days

+
+
+

Destination

+

Google

+
+
+
+

How was your experience with Review Flow Studio?

+

+ Hi Maya, thank you for choosing us. We would love to hear about your experience. +

+
+
+
+
+
+ +
+
+ {steps.map(([title, copy], index) => ( +
+
+ {index + 1} +
+

{title}

+

{copy}

+
+ ))} +
+
+ +
+
+
+

First MVP slice

+

A complete thin workflow, not just a screen.

+

+ The admin workspace lets a user connect payment webhooks, receive events, create transactions and customers, queue review requests, browse recent activity, and inspect the generated message. +

+
+
+ {features.map((feature) => ( +
+ + {feature} +
+ ))} +
+
+
+
+ +
+
+

© 2026 Review Flow. All rights reserved.

+
+ Privacy Policy + Terms of Use + Login +
+
+
); } @@ -163,4 +172,3 @@ export default function Starter() { Starter.getLayout = function getLayout(page: ReactElement) { return {page}; }; - diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index 5666163..fd4468b 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -44,7 +44,7 @@ export default function Login() { password: 'fc6e39e3', remember: true }) - const title = 'ReviewFlow' + const title = 'Review Flow' // Fetch Pexels image/video useEffect( () => { diff --git a/frontend/src/pages/privacy-policy.tsx b/frontend/src/pages/privacy-policy.tsx index 1d0980a..ad2df90 100644 --- a/frontend/src/pages/privacy-policy.tsx +++ b/frontend/src/pages/privacy-policy.tsx @@ -5,7 +5,7 @@ import LayoutGuest from '../layouts/Guest'; import { getPageTitle } from '../config'; export default function PrivacyPolicy() { - const title = 'ReviewFlow' + const title = 'Review Flow' const [projectUrl, setProjectUrl] = useState(''); useEffect(() => { diff --git a/frontend/src/pages/reviewflow.tsx b/frontend/src/pages/reviewflow.tsx new file mode 100644 index 0000000..b44461f --- /dev/null +++ b/frontend/src/pages/reviewflow.tsx @@ -0,0 +1,705 @@ +import { + mdiAccountPlusOutline, + mdiCreditCardOutline, + mdiEmailOutline, + mdiOpenInNew, + mdiRefresh, + mdiSend, + mdiStarCircleOutline, + mdiWebhook, +} from '@mdi/js'; +import axios from 'axios'; +import Head from 'next/head'; +import Link from 'next/link'; +import React, { + FormEvent, + ReactElement, + useEffect, + useMemo, + useState, +} from 'react'; +import PaymentProviderConnectors, { + ConnectorFormValues, +} from '../components/ReviewFlow/PaymentProviderConnectors'; +import BaseButton from '../components/BaseButton'; +import CardBox from '../components/CardBox'; +import FormField from '../components/FormField'; +import SectionMain from '../components/SectionMain'; +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; +import LayoutAuthenticated from '../layouts/Authenticated'; +import { getPageTitle } from '../config'; + +interface ReviewBusiness { + id?: string; + name?: string; + google_review_link?: string; +} + +interface ReviewCustomer { + name?: string; + email?: string; +} + +interface ReviewTransaction { + id: string; + payment_provider?: string; + amount?: string | number; + currency?: string; + paid_at?: string; + receipt_email?: string; + description?: string; + business?: ReviewBusiness; + customer?: ReviewCustomer; +} + +interface ReviewEvent { + id: string; + provider?: string; + provider_event_type?: string; + event_type?: string; + processed?: boolean; + processing_error?: string; + createdAt?: string; + business?: ReviewBusiness; +} + +interface ReviewRequest { + id: string; + status?: string; + scheduled_for?: string; + email_subject?: string; + email_body?: string; + review_link?: string; + createdAt?: string; + business?: ReviewBusiness; + customer?: ReviewCustomer; + transaction?: ReviewTransaction; +} + +interface SummaryResponse { + stats: { + pending: number; + sent: number; + clicked: number; + reviewed: number; + customers: number; + transactions: number; + paymentEvents: number; + }; + requests: ReviewRequest[]; + recentTransactions?: ReviewTransaction[]; + recentEvents?: ReviewEvent[]; +} + +const defaultForm = { + businessName: 'Review Flow Studio', + reviewLink: 'https://g.page/r/example/review', + delayDays: '7', + customerName: '', + customerEmail: '', + phone: '', +}; + +const statusStyles: Record = { + pending: 'bg-amber-100 text-amber-800 ring-amber-200', + sent: 'bg-sky-100 text-sky-800 ring-sky-200', + clicked: 'bg-violet-100 text-violet-800 ring-violet-200', + reviewed: 'bg-emerald-100 text-emerald-800 ring-emerald-200', + failed: 'bg-rose-100 text-rose-800 ring-rose-200', +}; + +function formatDate(value?: string | null) { + if (!value) return 'Not scheduled'; + + return new Intl.DateTimeFormat('en', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }).format(new Date(value)); +} + +function hasAuthToken() { + return ( + typeof window !== 'undefined' && Boolean(localStorage.getItem('token')) + ); +} + +function isUnauthorizedError(error: unknown) { + return axios.isAxiosError(error) && error.response?.status === 401; +} + +function formatAmount(amount?: string | number, currency?: string) { + const numericAmount = Number(amount); + + if (!Number.isFinite(numericAmount)) { + return 'Amount pending'; + } + + return new Intl.NumberFormat('en', { + style: 'currency', + currency: currency || 'USD', + }).format(numericAmount); +} + +export default function ReviewFlowWorkspace() { + const [form, setForm] = useState(defaultForm); + const [summary, setSummary] = useState(null); + const [selected, setSelected] = useState(null); + const [created, setCreated] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(''); + const [isClientReady, setIsClientReady] = useState(false); + + const requests = summary?.requests ?? []; + const recentTransactions = summary?.recentTransactions ?? []; + const recentEvents = summary?.recentEvents ?? []; + const stats = summary?.stats ?? { + pending: 0, + sent: 0, + clicked: 0, + reviewed: 0, + customers: 0, + transactions: 0, + paymentEvents: 0, + }; + + const previewDate = useMemo(() => { + if (!isClientReady) return 'after the selected delay'; + + const days = Math.max(0, Number(form.delayDays) || 0); + return formatDate( + new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString(), + ); + }, [form.delayDays, isClientReady]); + + const loadSummary = async () => { + setIsLoading(true); + try { + const response = await axios.get('/reviewflow/summary'); + setSummary(response.data); + if (!selected && response.data.requests?.length) { + setSelected(response.data.requests[0]); + } + setError(''); + } catch (requestError) { + if (!isUnauthorizedError(requestError)) { + console.error('Failed to load Review Flow summary:', requestError); + setError( + 'Could not load your review queue. Please refresh or try again.', + ); + } + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + setIsClientReady(true); + + if (!hasAuthToken()) { + setIsLoading(false); + return; + } + + loadSummary(); + }, []); + + const updateForm = (key: keyof typeof defaultForm, value: string) => { + setForm((current) => ({ ...current, [key]: value })); + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setIsSubmitting(true); + setError(''); + setCreated(null); + + try { + const response = await axios.post('/reviewflow/request', { + ...form, + delayDays: Number(form.delayDays), + }); + const newRequest = response.data.request; + setCreated(newRequest); + setSelected(newRequest); + setForm((current) => ({ + ...current, + customerName: '', + customerEmail: '', + phone: '', + })); + await loadSummary(); + } catch (requestError) { + console.error('Failed to create review request:', requestError); + if (axios.isAxiosError(requestError) && requestError.response?.data) { + setError(String(requestError.response.data)); + } else { + setError( + 'Could not create the review request. Please check the fields and try again.', + ); + } + } finally { + setIsSubmitting(false); + } + }; + + const handleProviderConnected = async ( + _business: unknown, + connectorForm: ConnectorFormValues, + ) => { + setForm((current) => ({ + ...current, + businessName: connectorForm.businessName, + reviewLink: connectorForm.reviewLink, + delayDays: connectorForm.delayDays, + })); + await loadSummary(); + }; + + return ( + <> + + {getPageTitle('Review Flow')} + + + + + + +
+
+
+

+ Webhook-first workflow · payment → customer → review request +

+

+ Let Stripe, Square, and PayPal feed the whole review engine. +

+

+ Connect each payment provider once. Every successful payment + webhook can create a customer, save a transaction, and queue a + review request automatically. +

+
+
+ {[ + ['Events', stats.paymentEvents], + ['Payments', stats.transactions], + ['Pending', stats.pending], + ['Customers', stats.customers], + ['Clicked', stats.clicked], + ['Reviewed', stats.reviewed], + ].map(([label, value]) => ( +
+
{value}
+
{label}
+
+ ))} +
+
+
+ + {created && ( +
+ Review request queued. {created.customer?.email} is + scheduled for {formatDate(created.scheduled_for)}. +
+ )} + {error && ( +
+ {error} +
+ )} + + + +
+ +
+
+

+ Manual fallback +

+

+ Queue a review request +

+

+ Use this when a payment did not come through a webhook, or + when you want to test the review queue manually. +

+
+
+ +
+
+ +
+ + + updateForm('businessName', event.target.value) + } + placeholder='Business name' + /> + + updateForm('reviewLink', event.target.value) + } + placeholder='https://g.page/.../review' + /> + + + + updateForm('customerName', event.target.value) + } + placeholder='Customer name' + /> + + updateForm('customerEmail', event.target.value) + } + placeholder='customer@example.com' + /> + + + + updateForm('delayDays', event.target.value) + } + placeholder='Delay days' + /> + updateForm('phone', event.target.value)} + placeholder='Optional phone' + /> + +
+ + +
+
+
+ +
+ +
+
+

+ Queue +

+

+ Recent requests +

+
+ +
+ + {isLoading ? ( +
+ Loading review queue... +
+ ) : requests.length === 0 ? ( +
+
+ +
+

+ No requests yet +

+

+ Create one manually or send a successful provider payment + webhook. +

+
+ ) : ( +
+ {requests.map((request) => { + const status = request.status || 'pending'; + return ( + + ); + })} +
+ )} +
+ + +

+ Detail +

+

+ Message preview +

+ {selected ? ( +
+
+

+ To +

+

+ {selected.customer?.email} +

+
+
+

+ Subject +

+

+ {selected.email_subject} +

+
+
+ {(selected.email_body || '') + .split('\n') + .map((line, index) => ( +

+ {line} +

+ ))} +
+
+

+ Review link +

+ + {selected.review_link} + +
+
+ ) : ( +
+ Select a request to preview the outgoing message. +
+ )} +
+
+
+ +
+ +
+
+

+ Webhook intake +

+

+ Recent payment events +

+
+ +
+ {recentEvents.length === 0 ? ( +
+ No provider webhooks received yet. +
+ ) : ( +
+ {recentEvents.map((event) => ( +
+
+
+

+ {(event.provider || 'provider').toUpperCase()} ·{' '} + {event.provider_event_type || + event.event_type || + 'unknown event'} +

+

+ {event.business?.name || 'Business'} ·{' '} + {formatDate(event.createdAt)} +

+ {event.processing_error && ( +

+ {event.processing_error} +

+ )} +
+ + {event.processed ? 'processed' : 'pending'} + +
+
+ ))} +
+ )} +
+ + +
+
+

+ Payments +

+

+ Recent transactions +

+
+ +
+ {recentTransactions.length === 0 ? ( +
+ No transactions created from webhooks yet. +
+ ) : ( +
+ {recentTransactions.map((transaction) => ( +
+
+
+

+ {formatAmount( + transaction.amount, + transaction.currency, + )} +

+

+ {transaction.payment_provider || 'provider'} ·{' '} + {transaction.customer?.email || + transaction.receipt_email || + 'No email'}{' '} + · {formatDate(transaction.paid_at)} +

+
+ + {transaction.currency || 'USD'} + +
+
+ ))} +
+ )} +
+
+
+ + ); +} + +ReviewFlowWorkspace.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/terms-of-use.tsx b/frontend/src/pages/terms-of-use.tsx index f643b95..dac8f9c 100644 --- a/frontend/src/pages/terms-of-use.tsx +++ b/frontend/src/pages/terms-of-use.tsx @@ -5,7 +5,7 @@ import LayoutGuest from '../layouts/Guest'; import { getPageTitle } from '../config'; export default function PrivacyPolicy() { - const title = 'ReviewFlow'; + const title = 'Review Flow'; const [projectUrl, setProjectUrl] = useState(''); useEffect(() => {