Autosave: 20260629-035900
This commit is contained in:
parent
9fe1095a1c
commit
e4186ae090
@ -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]: [
|
||||
|
||||
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
29
backend/src/routes/reviewflow-webhooks.js
Normal file
29
backend/src/routes/reviewflow-webhooks.js
Normal file
@ -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;
|
||||
236
backend/src/routes/reviewflow.js
Normal file
236
backend/src/routes/reviewflow.js
Normal file
@ -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;
|
||||
697
backend/src/services/reviewflow.js
Normal file
697
backend/src/services/reviewflow.js
Normal file
@ -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,
|
||||
};
|
||||
@ -39,7 +39,7 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
|
||||
>
|
||||
<div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0">
|
||||
|
||||
<b className="font-black">ReviewFlow</b>
|
||||
<b className="font-black">Review Flow</b>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
@ -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,
|
||||
|
||||
},
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
735
frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx
Normal file
735
frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx
Normal file
@ -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<void>;
|
||||
}
|
||||
|
||||
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<string, string[]> = {
|
||||
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<string, string> = {
|
||||
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<ConnectorFormValues>(connectorDefaults);
|
||||
const [connectors, setConnectors] = useState<ConnectorBusiness[]>([]);
|
||||
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<HTMLFormElement>) => {
|
||||
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 (
|
||||
<CardBox
|
||||
className={`${className} border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700`}
|
||||
>
|
||||
<div className='mb-6 grid gap-4 lg:grid-cols-[0.85fr_1.15fr] lg:items-start'>
|
||||
<div>
|
||||
<p className='text-sm font-bold uppercase tracking-[0.25em] text-indigo-500'>
|
||||
{eyebrow}
|
||||
</p>
|
||||
<h3 className='text-2xl font-black text-slate-900 dark:text-white'>
|
||||
{title}
|
||||
</h3>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400'>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<div className='rounded-2xl bg-slate-50 p-4 text-sm text-slate-600 ring-1 ring-slate-200 dark:bg-dark-800 dark:text-slate-300 dark:ring-dark-700'>
|
||||
<div className='mb-2 flex items-center gap-2 font-black text-slate-900 dark:text-white'>
|
||||
<BaseButton
|
||||
icon={mdiAlertCircleOutline}
|
||||
color='warning'
|
||||
roundedFull
|
||||
small
|
||||
/>
|
||||
Secure connection note
|
||||
</div>
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{connectorMessage && (
|
||||
<div className='mb-6 rounded-2xl border border-indigo-200 bg-indigo-50 p-4 text-indigo-900'>
|
||||
<strong>Connection update.</strong> {connectorMessage}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className='mb-6 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-rose-900'>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='mb-6 grid gap-3 md:grid-cols-3'>
|
||||
{providerOptions.map((provider) => {
|
||||
const isSelected = connectorForm.provider === provider.key;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={provider.key}
|
||||
type='button'
|
||||
onClick={() => updateConnectorForm('provider', provider.key)}
|
||||
className={`rounded-2xl border p-4 text-left transition hover:-translate-y-0.5 hover:shadow-lg ${
|
||||
isSelected
|
||||
? 'border-indigo-300 bg-indigo-50 ring-2 ring-indigo-100 dark:bg-indigo-950/30 dark:ring-indigo-900'
|
||||
: 'border-slate-200 bg-white dark:border-dark-700 dark:bg-dark-900'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`mb-3 h-2 rounded-full bg-gradient-to-r ${providerGradient[provider.key] || providerGradient.stripe}`}
|
||||
/>
|
||||
<p className='font-black text-slate-900 dark:text-white'>
|
||||
Connect {provider.label}
|
||||
</p>
|
||||
<p className='mt-1 text-sm text-slate-500 dark:text-slate-400'>
|
||||
{provider.description}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleConnectorSubmit}
|
||||
className='mb-6 rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-dark-700 dark:bg-dark-800'
|
||||
>
|
||||
<FormField
|
||||
label='Provider and business'
|
||||
help='Choose the provider, then use the same business name to reuse its review settings.'
|
||||
>
|
||||
<select
|
||||
value={connectorForm.provider}
|
||||
onChange={(event) =>
|
||||
updateConnectorForm('provider', event.target.value)
|
||||
}
|
||||
>
|
||||
<option value='stripe'>Stripe</option>
|
||||
<option value='paypal'>PayPal</option>
|
||||
<option value='square'>Square</option>
|
||||
</select>
|
||||
<input
|
||||
required
|
||||
value={connectorForm.businessName}
|
||||
onChange={(event) =>
|
||||
updateConnectorForm('businessName', event.target.value)
|
||||
}
|
||||
placeholder='Business name'
|
||||
/>
|
||||
<input
|
||||
value={connectorForm.accountReference}
|
||||
onChange={(event) =>
|
||||
updateConnectorForm('accountReference', event.target.value)
|
||||
}
|
||||
placeholder='Optional account / merchant ID'
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label='Review trigger settings'
|
||||
help={`Successful payments will queue reviews for ${connectorPreviewDate}.`}
|
||||
>
|
||||
<input
|
||||
required
|
||||
type='url'
|
||||
value={connectorForm.reviewLink}
|
||||
onChange={(event) =>
|
||||
updateConnectorForm('reviewLink', event.target.value)
|
||||
}
|
||||
placeholder='https://g.page/.../review'
|
||||
/>
|
||||
<input
|
||||
min='0'
|
||||
max='30'
|
||||
type='number'
|
||||
value={connectorForm.delayDays}
|
||||
onChange={(event) =>
|
||||
updateConnectorForm('delayDays', event.target.value)
|
||||
}
|
||||
placeholder='Delay days'
|
||||
/>
|
||||
</FormField>
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
<BaseButton
|
||||
type='submit'
|
||||
icon={mdiWebhook}
|
||||
label={
|
||||
isConnectorSubmitting
|
||||
? 'Connecting...'
|
||||
: `Connect ${selectedProvider.label}`
|
||||
}
|
||||
color='info'
|
||||
disabled={isConnectorSubmitting}
|
||||
/>
|
||||
<BaseButton
|
||||
type='button'
|
||||
icon={mdiRefresh}
|
||||
label='Refresh connectors'
|
||||
color='whiteDark'
|
||||
onClick={loadConnectors}
|
||||
disabled={isConnectorLoading}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className='mb-6 rounded-2xl border border-slate-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900'>
|
||||
<div>
|
||||
<p className='text-sm font-bold uppercase tracking-[0.25em] text-emerald-500'>
|
||||
Installation guide
|
||||
</p>
|
||||
<h4 className='text-xl font-black text-slate-900 dark:text-white'>
|
||||
How to install payment webhooks
|
||||
</h4>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400'>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='mt-5 grid gap-4 xl:grid-cols-3'>
|
||||
{providerOptions.map((provider) => {
|
||||
const setup = providerSetupDetails[provider.key];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${provider.key}-setup-guide`}
|
||||
className='overflow-hidden rounded-2xl border border-slate-200 bg-slate-50 dark:border-dark-700 dark:bg-dark-800'
|
||||
>
|
||||
<div
|
||||
className={`bg-gradient-to-r ${providerGradient[provider.key] || providerGradient.stripe} p-4 text-white`}
|
||||
>
|
||||
<p className='text-xs font-bold uppercase tracking-[0.2em] text-white/70'>
|
||||
{provider.label}
|
||||
</p>
|
||||
<h5 className='text-lg font-black'>Webhook setup</h5>
|
||||
</div>
|
||||
<div className='space-y-4 p-4 text-sm text-slate-600 dark:text-slate-300'>
|
||||
<div>
|
||||
<p className='mb-1 text-xs font-bold uppercase tracking-widest text-slate-400'>
|
||||
Dashboard path
|
||||
</p>
|
||||
<p className='font-semibold text-slate-900 dark:text-white'>
|
||||
{setup.dashboardPath}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className='mb-2 text-xs font-bold uppercase tracking-widest text-slate-400'>
|
||||
Install steps
|
||||
</p>
|
||||
<ol className='list-decimal space-y-2 pl-4'>
|
||||
{setup.steps.map((step, index) => (
|
||||
<li key={`${provider.key}-step-${index}`}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className='mb-2 text-xs font-bold uppercase tracking-widest text-slate-400'>
|
||||
Events to enable
|
||||
</p>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{setup.requiredEvents.map((eventName) => (
|
||||
<code
|
||||
key={`${provider.key}-${eventName}`}
|
||||
className='rounded-lg bg-slate-950 px-2 py-1 text-xs text-emerald-200'
|
||||
>
|
||||
{eventName}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='rounded-xl bg-white p-3 text-xs leading-5 ring-1 ring-slate-200 dark:bg-dark-900 dark:ring-dark-700'>
|
||||
<strong>Test after saving:</strong> {setup.testTip}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mb-4 flex flex-wrap items-center justify-between gap-3'>
|
||||
<div>
|
||||
<p className='text-sm font-bold uppercase tracking-[0.25em] text-slate-400'>
|
||||
Connected accounts
|
||||
</p>
|
||||
<h4 className='text-xl font-black text-slate-900 dark:text-white'>
|
||||
{providerSummary.connectedCount} of {providerSummary.totalCount}{' '}
|
||||
provider slots connected
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isConnectorLoading ? (
|
||||
<div className='rounded-2xl border border-dashed border-slate-200 p-8 text-center text-slate-500'>
|
||||
Loading payment connectors...
|
||||
</div>
|
||||
) : connectors.length === 0 ? (
|
||||
<div className='rounded-2xl border border-dashed border-slate-200 bg-white p-8 text-center dark:border-dark-700 dark:bg-dark-900'>
|
||||
<BaseButton icon={mdiWebhook} color='info' roundedFull />
|
||||
<p className='mt-3 font-bold text-slate-900 dark:text-white'>
|
||||
No payment providers connected yet
|
||||
</p>
|
||||
<p className='mt-1 text-sm text-slate-500'>
|
||||
Choose Stripe, PayPal, or Square above to generate your first secure
|
||||
webhook URL.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-5'>
|
||||
{connectors.map((business) => (
|
||||
<div
|
||||
key={business.id}
|
||||
className='rounded-3xl border border-slate-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900'
|
||||
>
|
||||
<div className='mb-4 flex flex-wrap items-center justify-between gap-3'>
|
||||
<div>
|
||||
<h4 className='text-xl font-black text-slate-900 dark:text-white'>
|
||||
{business.name}
|
||||
</h4>
|
||||
<p className='text-sm text-slate-500'>
|
||||
Default review delay: {business.delay_days ?? 0} days
|
||||
</p>
|
||||
</div>
|
||||
<BaseButton
|
||||
href='/transactions/transactions-list'
|
||||
icon={mdiCreditCardOutline}
|
||||
label='View payments'
|
||||
color='whiteDark'
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-4 xl:grid-cols-3'>
|
||||
{business.providers.map((provider) => (
|
||||
<div
|
||||
key={`${business.id}-${provider.key}`}
|
||||
className='overflow-hidden rounded-2xl border border-slate-200 bg-slate-50 dark:border-dark-700 dark:bg-dark-800'
|
||||
>
|
||||
<div
|
||||
className={`bg-gradient-to-r ${providerGradient[provider.key] || providerGradient.stripe} p-4 text-white`}
|
||||
>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div>
|
||||
<p className='text-xs font-bold uppercase tracking-[0.2em] text-white/70'>
|
||||
{provider.label}
|
||||
</p>
|
||||
<h5 className='text-lg font-black'>
|
||||
Webhook receiver
|
||||
</h5>
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-bold ${provider.connected ? 'bg-emerald-100 text-emerald-900' : 'bg-white/20 text-white'}`}
|
||||
>
|
||||
{provider.connected ? 'Connected' : 'Not connected'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-4 p-4'>
|
||||
<div>
|
||||
<p className='mb-1 text-xs font-bold uppercase tracking-widest text-slate-400'>
|
||||
Webhook URL
|
||||
</p>
|
||||
<code className='block break-all rounded-xl bg-slate-950 p-3 text-xs leading-5 text-emerald-200'>
|
||||
{provider.webhook_url ||
|
||||
'Connect this provider to reveal its secure webhook endpoint.'}
|
||||
</code>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<BaseButton
|
||||
type='button'
|
||||
icon={
|
||||
provider.webhook_url &&
|
||||
copiedUrl === provider.webhook_url
|
||||
? mdiCheckCircleOutline
|
||||
: mdiContentCopy
|
||||
}
|
||||
label={
|
||||
provider.webhook_url &&
|
||||
copiedUrl === provider.webhook_url
|
||||
? 'Copied'
|
||||
: 'Copy URL'
|
||||
}
|
||||
color='info'
|
||||
small
|
||||
disabled={!provider.webhook_url}
|
||||
onClick={() => copyWebhookUrl(provider.webhook_url)}
|
||||
/>
|
||||
<BaseButton
|
||||
type='button'
|
||||
icon={mdiRefresh}
|
||||
label='Rotate URL'
|
||||
color='whiteDark'
|
||||
small
|
||||
disabled={!provider.webhook_url}
|
||||
onClick={() =>
|
||||
rotateWebhookToken(business.id, provider.key)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className='rounded-xl bg-white p-3 text-xs text-slate-600 ring-1 ring-slate-200 dark:bg-dark-900 dark:text-slate-300 dark:ring-dark-700'>
|
||||
<p className='mb-2 font-black text-slate-900 dark:text-white'>
|
||||
Setup steps
|
||||
</p>
|
||||
<ol className='list-decimal space-y-1 pl-4'>
|
||||
{(providerInstructions[provider.key] || []).map(
|
||||
(instruction) => (
|
||||
<li key={instruction}>{instruction}</li>
|
||||
),
|
||||
)}
|
||||
</ol>
|
||||
{provider.webhook_token_last4 && (
|
||||
<p className='mt-3 text-slate-400'>
|
||||
Secret token ends in: ****
|
||||
{provider.webhook_token_last4}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBox>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"
|
||||
|
||||
69
frontend/src/pages/connect.tsx
Normal file
69
frontend/src/pages/connect.tsx
Normal file
@ -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 (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Connect')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiConnection} title='Connect' main>
|
||||
<BaseButton
|
||||
href='/reviewflow'
|
||||
icon={mdiOpenInNew}
|
||||
label='Review Flow'
|
||||
color='whiteDark'
|
||||
/>
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<div className='mb-6 overflow-hidden rounded-3xl bg-gradient-to-br from-slate-950 via-blue-950 to-indigo-950 p-6 text-white shadow-2xl'>
|
||||
<div className='grid gap-6 lg:grid-cols-[1.15fr_0.85fr] lg:items-center'>
|
||||
<div>
|
||||
<p className='mb-3 inline-flex rounded-full bg-white/10 px-4 py-1 text-sm font-semibold text-sky-200 ring-1 ring-white/20'>
|
||||
Payment provider setup
|
||||
</p>
|
||||
<h2 className='max-w-3xl text-4xl font-black tracking-tight md:text-5xl'>
|
||||
Connect Stripe, PayPal, and Square.
|
||||
</h2>
|
||||
<p className='mt-4 max-w-2xl text-base text-slate-200 md:text-lg'>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div className='rounded-3xl bg-white/10 p-5 ring-1 ring-white/15 backdrop-blur'>
|
||||
<div className='mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-sky-400/20 text-sky-100'>
|
||||
<BaseButton icon={mdiWebhook} color='info' roundedFull />
|
||||
</div>
|
||||
<h3 className='text-xl font-black'>How connection works</h3>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-200'>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PaymentProviderConnectors
|
||||
eyebrow='Provider connections'
|
||||
title='Connect your payment accounts'
|
||||
description='Choose Stripe, PayPal, or Square below. Each provider gets its own secure webhook URL for the business you configure.'
|
||||
/>
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ConnectPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
@ -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) => (
|
||||
<div
|
||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
||||
style={{
|
||||
backgroundImage: `${
|
||||
image
|
||||
? `url(${image?.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
>
|
||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={image?.photographer_url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Photo by {image?.photographer} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const videoBlock = (video) => {
|
||||
if (video?.video_files?.length > 0) {
|
||||
return (
|
||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
||||
<video
|
||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
>
|
||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={video?.user?.url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Video by {video.user.name} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
contentPosition === 'background'
|
||||
? {
|
||||
backgroundImage: `${
|
||||
illustrationImage
|
||||
? `url(${illustrationImage.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<div className="min-h-screen bg-[#F7F8FC] text-slate-950">
|
||||
<Head>
|
||||
<title>{getPageTitle('Starter Page')}</title>
|
||||
<title>{getPageTitle('Review Flow')}</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Review Flow helps businesses queue and track review requests after customer purchases or visits."
|
||||
/>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<div
|
||||
className={`flex ${
|
||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
||||
} min-h-screen w-full`}
|
||||
>
|
||||
{contentType === 'image' && contentPosition !== 'background'
|
||||
? imageBlock(illustrationImage)
|
||||
: null}
|
||||
{contentType === 'video' && contentPosition !== 'background'
|
||||
? videoBlock(illustrationVideo)
|
||||
: null}
|
||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||
<CardBoxComponentTitle title="Welcome to your ReviewFlow app!"/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
||||
<p className='text-center text-gray-500'>For guides and documentation please check
|
||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
||||
</div>
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
href='/login'
|
||||
label='Login'
|
||||
color='info'
|
||||
className='w-full'
|
||||
/>
|
||||
|
||||
</BaseButtons>
|
||||
</CardBox>
|
||||
<header className="sticky top-0 z-20 border-b border-white/70 bg-white/80 backdrop-blur-xl">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
|
||||
<Link href="/" className="flex items-center gap-3 font-black tracking-tight">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#101828] text-white shadow-lg shadow-indigo-950/20">
|
||||
★
|
||||
</span>
|
||||
<span className="text-xl">Review Flow</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-3">
|
||||
<BaseButton href="/login" icon={mdiLogin} label="Login" color="whiteDark" />
|
||||
<BaseButton href="/reviewflow" icon={mdiArrowRight} label="Admin interface" color="info" />
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</SectionFullScreen>
|
||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section className="relative isolate overflow-hidden px-6 py-20 md:py-28">
|
||||
<div className="absolute left-1/2 top-0 -z-10 h-[640px] w-[920px] -translate-x-1/2 rounded-full bg-[radial-gradient(circle_at_center,#7C3AED_0%,#10B981_38%,transparent_68%)] opacity-20 blur-3xl" />
|
||||
<div className="mx-auto grid max-w-7xl gap-12 lg:grid-cols-[1.05fr_0.95fr] lg:items-center">
|
||||
<div>
|
||||
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm font-bold text-emerald-700">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-500" />
|
||||
Review automation for modern local businesses
|
||||
</div>
|
||||
<h1 className="max-w-4xl text-5xl font-black leading-[0.95] tracking-tight text-slate-950 md:text-7xl">
|
||||
Ask at the perfect moment. Earn more five-star reviews.
|
||||
</h1>
|
||||
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-600">
|
||||
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.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<BaseButton href="/reviewflow" icon={mdiStarCircleOutline} label="Open Review Flow" color="info" className="shadow-xl shadow-indigo-600/20" />
|
||||
<BaseButton href="/login" icon={mdiShieldCheckOutline} label="Login to admin" color="whiteDark" />
|
||||
</div>
|
||||
<div className="mt-10 grid max-w-2xl gap-3 sm:grid-cols-3">
|
||||
{metrics.map(([value, label]) => (
|
||||
<div key={label} className="rounded-3xl border border-white bg-white/80 p-5 shadow-xl shadow-slate-200/60">
|
||||
<p className="text-3xl font-black text-slate-950">{value}</p>
|
||||
<p className="mt-1 text-sm text-slate-500">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardBox className="border-0 bg-white/90 shadow-2xl shadow-indigo-950/10 ring-1 ring-slate-200/70" cardBoxClassName="p-0">
|
||||
<div className="rounded-t-3xl bg-[#101828] p-5 text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-bold uppercase tracking-[0.25em] text-emerald-300">Live workflow</p>
|
||||
<h2 className="mt-2 text-2xl font-black">Review request queued</h2>
|
||||
</div>
|
||||
<span className="rounded-full bg-emerald-400/20 px-3 py-1 text-sm font-bold text-emerald-200 ring-1 ring-emerald-300/30">
|
||||
pending
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-5 p-6">
|
||||
<div className="rounded-3xl bg-slate-50 p-5">
|
||||
<p className="text-xs font-black uppercase tracking-[0.25em] text-slate-400">Customer</p>
|
||||
<p className="mt-2 text-lg font-black">Maya Chen</p>
|
||||
<p className="text-slate-500">maya@example.com</p>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-3xl bg-indigo-50 p-5 text-indigo-950">
|
||||
<p className="text-sm font-bold text-indigo-500">Scheduled</p>
|
||||
<p className="text-2xl font-black">+7 days</p>
|
||||
</div>
|
||||
<div className="rounded-3xl bg-emerald-50 p-5 text-emerald-950">
|
||||
<p className="text-sm font-bold text-emerald-600">Destination</p>
|
||||
<p className="text-2xl font-black">Google</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-dashed border-slate-200 p-5">
|
||||
<p className="font-black">How was your experience with Review Flow Studio?</p>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||
Hi Maya, thank you for choosing us. We would love to hear about your experience.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="px-6 pb-20">
|
||||
<div className="mx-auto grid max-w-7xl gap-6 lg:grid-cols-3">
|
||||
{steps.map(([title, copy], index) => (
|
||||
<div key={title} className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-xl shadow-slate-200/50">
|
||||
<div className="mb-5 flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-950 text-lg font-black text-white">
|
||||
{index + 1}
|
||||
</div>
|
||||
<h3 className="text-2xl font-black">{title}</h3>
|
||||
<p className="mt-3 leading-7 text-slate-600">{copy}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-[#101828] px-6 py-20 text-white">
|
||||
<div className="mx-auto grid max-w-7xl gap-10 lg:grid-cols-[0.8fr_1.2fr] lg:items-center">
|
||||
<div>
|
||||
<p className="text-sm font-black uppercase tracking-[0.3em] text-emerald-300">First MVP slice</p>
|
||||
<h2 className="mt-4 text-4xl font-black tracking-tight md:text-5xl">A complete thin workflow, not just a screen.</h2>
|
||||
<p className="mt-5 leading-8 text-slate-300">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{features.map((feature) => (
|
||||
<div key={feature} className="flex items-start gap-3 rounded-3xl bg-white/10 p-5 ring-1 ring-white/10">
|
||||
<span className="mt-1 text-emerald-300"><svg className="h-5 w-5" viewBox="0 0 24 24"><path fill="currentColor" d={mdiCheckCircleOutline} /></svg></span>
|
||||
<span className="font-semibold text-slate-100">{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="bg-white px-6 py-8">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-4 text-sm text-slate-500 md:flex-row md:items-center md:justify-between">
|
||||
<p>© 2026 Review Flow. All rights reserved.</p>
|
||||
<div className="flex gap-5">
|
||||
<Link href="/privacy-policy/" className="hover:text-slate-950">Privacy Policy</Link>
|
||||
<Link href="/terms-of-use/" className="hover:text-slate-950">Terms of Use</Link>
|
||||
<Link href="/login" className="font-bold text-slate-950">Login</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -163,4 +172,3 @@ export default function Starter() {
|
||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
|
||||
@ -44,7 +44,7 @@ export default function Login() {
|
||||
password: 'fc6e39e3',
|
||||
remember: true })
|
||||
|
||||
const title = 'ReviewFlow'
|
||||
const title = 'Review Flow'
|
||||
|
||||
// Fetch Pexels image/video
|
||||
useEffect( () => {
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
705
frontend/src/pages/reviewflow.tsx
Normal file
705
frontend/src/pages/reviewflow.tsx
Normal file
@ -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<string, string> = {
|
||||
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<SummaryResponse | null>(null);
|
||||
const [selected, setSelected] = useState<ReviewRequest | null>(null);
|
||||
const [created, setCreated] = useState<ReviewRequest | null>(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<HTMLFormElement>) => {
|
||||
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 (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Review Flow')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton
|
||||
icon={mdiStarCircleOutline}
|
||||
title='Review Flow command center'
|
||||
main
|
||||
>
|
||||
<BaseButton
|
||||
href='/review_requests/review_requests-list'
|
||||
icon={mdiOpenInNew}
|
||||
label='Open CRUD'
|
||||
color='whiteDark'
|
||||
/>
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<div className='mb-6 overflow-hidden rounded-3xl bg-gradient-to-br from-slate-950 via-indigo-950 to-emerald-950 p-6 text-white shadow-2xl'>
|
||||
<div className='grid gap-6 lg:grid-cols-[1.2fr_0.8fr] lg:items-center'>
|
||||
<div>
|
||||
<p className='mb-3 inline-flex rounded-full bg-white/10 px-4 py-1 text-sm font-semibold text-emerald-200 ring-1 ring-white/20'>
|
||||
Webhook-first workflow · payment → customer → review request
|
||||
</p>
|
||||
<h2 className='max-w-3xl text-4xl font-black tracking-tight md:text-5xl'>
|
||||
Let Stripe, Square, and PayPal feed the whole review engine.
|
||||
</h2>
|
||||
<p className='mt-4 max-w-2xl text-base text-slate-200 md:text-lg'>
|
||||
Connect each payment provider once. Every successful payment
|
||||
webhook can create a customer, save a transaction, and queue a
|
||||
review request automatically.
|
||||
</p>
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
{[
|
||||
['Events', stats.paymentEvents],
|
||||
['Payments', stats.transactions],
|
||||
['Pending', stats.pending],
|
||||
['Customers', stats.customers],
|
||||
['Clicked', stats.clicked],
|
||||
['Reviewed', stats.reviewed],
|
||||
].map(([label, value]) => (
|
||||
<div
|
||||
key={label}
|
||||
className='rounded-2xl bg-white/10 p-4 ring-1 ring-white/15 backdrop-blur'
|
||||
>
|
||||
<div className='text-3xl font-black'>{value}</div>
|
||||
<div className='text-sm text-slate-300'>{label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{created && (
|
||||
<div className='mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-emerald-900'>
|
||||
<strong>Review request queued.</strong> {created.customer?.email} is
|
||||
scheduled for {formatDate(created.scheduled_for)}.
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className='mb-6 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-rose-900'>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PaymentProviderConnectors
|
||||
className='mb-6'
|
||||
onConnected={handleProviderConnected}
|
||||
/>
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-[0.95fr_1.05fr]'>
|
||||
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
|
||||
<div className='mb-6 flex items-start justify-between gap-4'>
|
||||
<div>
|
||||
<p className='text-sm font-bold uppercase tracking-[0.25em] text-emerald-500'>
|
||||
Manual fallback
|
||||
</p>
|
||||
<h3 className='text-2xl font-black text-slate-900 dark:text-white'>
|
||||
Queue a review request
|
||||
</h3>
|
||||
<p className='mt-2 text-sm text-slate-500 dark:text-slate-400'>
|
||||
Use this when a payment did not come through a webhook, or
|
||||
when you want to test the review queue manually.
|
||||
</p>
|
||||
</div>
|
||||
<div className='rounded-2xl bg-emerald-100 p-3 text-emerald-700'>
|
||||
<BaseButton
|
||||
icon={mdiAccountPlusOutline}
|
||||
color='success'
|
||||
roundedFull
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<FormField
|
||||
label='Business setup'
|
||||
help='Use the review link where customers should land.'
|
||||
>
|
||||
<input
|
||||
required
|
||||
value={form.businessName}
|
||||
onChange={(event) =>
|
||||
updateForm('businessName', event.target.value)
|
||||
}
|
||||
placeholder='Business name'
|
||||
/>
|
||||
<input
|
||||
required
|
||||
type='url'
|
||||
value={form.reviewLink}
|
||||
onChange={(event) =>
|
||||
updateForm('reviewLink', event.target.value)
|
||||
}
|
||||
placeholder='https://g.page/.../review'
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label='Customer'
|
||||
help='Webhook payments fill this automatically when the provider sends a customer email.'
|
||||
>
|
||||
<input
|
||||
value={form.customerName}
|
||||
onChange={(event) =>
|
||||
updateForm('customerName', event.target.value)
|
||||
}
|
||||
placeholder='Customer name'
|
||||
/>
|
||||
<input
|
||||
required
|
||||
type='email'
|
||||
value={form.customerEmail}
|
||||
onChange={(event) =>
|
||||
updateForm('customerEmail', event.target.value)
|
||||
}
|
||||
placeholder='customer@example.com'
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label='Delay and phone'
|
||||
help={`Preview: scheduled for ${previewDate}`}
|
||||
>
|
||||
<input
|
||||
min='0'
|
||||
max='30'
|
||||
type='number'
|
||||
value={form.delayDays}
|
||||
onChange={(event) =>
|
||||
updateForm('delayDays', event.target.value)
|
||||
}
|
||||
placeholder='Delay days'
|
||||
/>
|
||||
<input
|
||||
value={form.phone}
|
||||
onChange={(event) => updateForm('phone', event.target.value)}
|
||||
placeholder='Optional phone'
|
||||
/>
|
||||
</FormField>
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
<BaseButton
|
||||
type='submit'
|
||||
icon={mdiSend}
|
||||
label={isSubmitting ? 'Queueing...' : 'Queue review request'}
|
||||
color='info'
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<BaseButton
|
||||
type='button'
|
||||
icon={mdiRefresh}
|
||||
label='Refresh queue'
|
||||
color='whiteDark'
|
||||
onClick={loadSummary}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</CardBox>
|
||||
|
||||
<div className='grid gap-6 lg:grid-cols-[0.95fr_1.05fr] xl:grid-cols-1 2xl:grid-cols-[0.95fr_1.05fr]'>
|
||||
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
|
||||
<div className='mb-5 flex items-center justify-between'>
|
||||
<div>
|
||||
<p className='text-sm font-bold uppercase tracking-[0.25em] text-indigo-500'>
|
||||
Queue
|
||||
</p>
|
||||
<h3 className='text-2xl font-black text-slate-900 dark:text-white'>
|
||||
Recent requests
|
||||
</h3>
|
||||
</div>
|
||||
<BaseButton
|
||||
href='/review_requests/review_requests-list'
|
||||
label='All'
|
||||
color='whiteDark'
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className='rounded-2xl border border-dashed border-slate-200 p-8 text-center text-slate-500'>
|
||||
Loading review queue...
|
||||
</div>
|
||||
) : requests.length === 0 ? (
|
||||
<div className='rounded-2xl border border-dashed border-slate-200 bg-slate-50 p-8 text-center dark:bg-dark-800'>
|
||||
<div className='mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-indigo-100 text-indigo-700'>
|
||||
<BaseButton
|
||||
icon={mdiEmailOutline}
|
||||
color='info'
|
||||
roundedFull
|
||||
/>
|
||||
</div>
|
||||
<p className='font-bold text-slate-900 dark:text-white'>
|
||||
No requests yet
|
||||
</p>
|
||||
<p className='mt-1 text-sm text-slate-500'>
|
||||
Create one manually or send a successful provider payment
|
||||
webhook.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
{requests.map((request) => {
|
||||
const status = request.status || 'pending';
|
||||
return (
|
||||
<button
|
||||
key={request.id}
|
||||
type='button'
|
||||
onClick={() => setSelected(request)}
|
||||
className={`w-full rounded-2xl border p-4 text-left transition hover:-translate-y-0.5 hover:shadow-lg ${selected?.id === request.id ? 'border-indigo-300 bg-indigo-50 dark:bg-indigo-950/30' : 'border-slate-200 bg-white dark:border-dark-700 dark:bg-dark-900'}`}
|
||||
>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div>
|
||||
<p className='font-black text-slate-900 dark:text-white'>
|
||||
{request.customer?.name ||
|
||||
request.customer?.email ||
|
||||
'Customer'}
|
||||
</p>
|
||||
<p className='text-sm text-slate-500'>
|
||||
{request.business?.name || 'Business'} ·{' '}
|
||||
{formatDate(request.scheduled_for)}
|
||||
</p>
|
||||
{request.transaction?.payment_provider && (
|
||||
<p className='mt-1 text-xs font-bold uppercase tracking-widest text-emerald-600'>
|
||||
From {request.transaction.payment_provider}{' '}
|
||||
payment
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 text-xs font-bold ring-1 ${statusStyles[status] || statusStyles.pending}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardBox>
|
||||
|
||||
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
|
||||
<p className='text-sm font-bold uppercase tracking-[0.25em] text-fuchsia-500'>
|
||||
Detail
|
||||
</p>
|
||||
<h3 className='mb-5 text-2xl font-black text-slate-900 dark:text-white'>
|
||||
Message preview
|
||||
</h3>
|
||||
{selected ? (
|
||||
<div className='space-y-4'>
|
||||
<div className='rounded-2xl bg-slate-50 p-4 dark:bg-dark-800'>
|
||||
<p className='text-xs font-bold uppercase tracking-widest text-slate-400'>
|
||||
To
|
||||
</p>
|
||||
<p className='font-bold text-slate-900 dark:text-white'>
|
||||
{selected.customer?.email}
|
||||
</p>
|
||||
</div>
|
||||
<div className='rounded-2xl bg-slate-50 p-4 dark:bg-dark-800'>
|
||||
<p className='text-xs font-bold uppercase tracking-widest text-slate-400'>
|
||||
Subject
|
||||
</p>
|
||||
<p className='font-bold text-slate-900 dark:text-white'>
|
||||
{selected.email_subject}
|
||||
</p>
|
||||
</div>
|
||||
<div className='rounded-2xl border border-slate-200 bg-white p-5 text-sm leading-6 text-slate-700 shadow-inner dark:border-dark-700 dark:bg-dark-900 dark:text-slate-200'>
|
||||
{(selected.email_body || '')
|
||||
.split('\n')
|
||||
.map((line, index) => (
|
||||
<p
|
||||
key={`${selected.id}-${index}-${line || 'space'}`}
|
||||
className={line ? '' : 'h-4'}
|
||||
>
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<div className='rounded-2xl bg-gradient-to-r from-indigo-600 to-emerald-500 p-4 text-white'>
|
||||
<p className='text-xs font-bold uppercase tracking-widest text-white/70'>
|
||||
Review link
|
||||
</p>
|
||||
<Link
|
||||
href={selected.review_link || '#'}
|
||||
target='_blank'
|
||||
className='break-all font-bold underline underline-offset-4'
|
||||
>
|
||||
{selected.review_link}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='rounded-2xl border border-dashed border-slate-200 p-8 text-center text-slate-500'>
|
||||
Select a request to preview the outgoing message.
|
||||
</div>
|
||||
)}
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-6 grid gap-6 lg:grid-cols-2'>
|
||||
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
|
||||
<div className='mb-5 flex items-center justify-between'>
|
||||
<div>
|
||||
<p className='text-sm font-bold uppercase tracking-[0.25em] text-sky-500'>
|
||||
Webhook intake
|
||||
</p>
|
||||
<h3 className='text-2xl font-black text-slate-900 dark:text-white'>
|
||||
Recent payment events
|
||||
</h3>
|
||||
</div>
|
||||
<BaseButton
|
||||
href='/stripe_events/stripe_events-list'
|
||||
icon={mdiWebhook}
|
||||
label='Events'
|
||||
color='whiteDark'
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
{recentEvents.length === 0 ? (
|
||||
<div className='rounded-2xl border border-dashed border-slate-200 p-8 text-center text-slate-500'>
|
||||
No provider webhooks received yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
{recentEvents.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className='rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-dark-700 dark:bg-dark-800'
|
||||
>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div>
|
||||
<p className='font-black text-slate-900 dark:text-white'>
|
||||
{(event.provider || 'provider').toUpperCase()} ·{' '}
|
||||
{event.provider_event_type ||
|
||||
event.event_type ||
|
||||
'unknown event'}
|
||||
</p>
|
||||
<p className='text-sm text-slate-500'>
|
||||
{event.business?.name || 'Business'} ·{' '}
|
||||
{formatDate(event.createdAt)}
|
||||
</p>
|
||||
{event.processing_error && (
|
||||
<p className='mt-1 text-xs text-amber-700'>
|
||||
{event.processing_error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 text-xs font-bold ${event.processed ? 'bg-emerald-100 text-emerald-800' : 'bg-amber-100 text-amber-800'}`}
|
||||
>
|
||||
{event.processed ? 'processed' : 'pending'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBox>
|
||||
|
||||
<CardBox className='border-0 shadow-xl ring-1 ring-slate-200/70 dark:ring-dark-700'>
|
||||
<div className='mb-5 flex items-center justify-between'>
|
||||
<div>
|
||||
<p className='text-sm font-bold uppercase tracking-[0.25em] text-emerald-500'>
|
||||
Payments
|
||||
</p>
|
||||
<h3 className='text-2xl font-black text-slate-900 dark:text-white'>
|
||||
Recent transactions
|
||||
</h3>
|
||||
</div>
|
||||
<BaseButton
|
||||
href='/transactions/transactions-list'
|
||||
icon={mdiCreditCardOutline}
|
||||
label='Payments'
|
||||
color='whiteDark'
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
{recentTransactions.length === 0 ? (
|
||||
<div className='rounded-2xl border border-dashed border-slate-200 p-8 text-center text-slate-500'>
|
||||
No transactions created from webhooks yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
{recentTransactions.map((transaction) => (
|
||||
<div
|
||||
key={transaction.id}
|
||||
className='rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-dark-700 dark:bg-dark-800'
|
||||
>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div>
|
||||
<p className='font-black text-slate-900 dark:text-white'>
|
||||
{formatAmount(
|
||||
transaction.amount,
|
||||
transaction.currency,
|
||||
)}
|
||||
</p>
|
||||
<p className='text-sm text-slate-500'>
|
||||
{transaction.payment_provider || 'provider'} ·{' '}
|
||||
{transaction.customer?.email ||
|
||||
transaction.receipt_email ||
|
||||
'No email'}{' '}
|
||||
· {formatDate(transaction.paid_at)}
|
||||
</p>
|
||||
</div>
|
||||
<span className='rounded-full bg-slate-900 px-3 py-1 text-xs font-bold text-white dark:bg-white dark:text-slate-900'>
|
||||
{transaction.currency || 'USD'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBox>
|
||||
</div>
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ReviewFlowWorkspace.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
@ -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(() => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user