Autosave: 20260629-035900

This commit is contained in:
Flatlogic Bot 2026-06-29 03:58:56 +00:00
parent 9fe1095a1c
commit e4186ae090
25 changed files with 2991 additions and 211 deletions

View File

@ -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]: [

View File

@ -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;
}
},
};

View File

@ -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,
});

View File

@ -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: {

View File

@ -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: {

View File

@ -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,
});

View File

@ -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);

View 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;

View 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;

View 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,
};

View File

@ -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>

View File

@ -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,
},

View File

@ -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'

View 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>
);
}

View File

@ -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,

View File

@ -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,

View File

@ -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'

View File

@ -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;

View File

@ -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"

View 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>;
};

View File

@ -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>;
};

View File

@ -44,7 +44,7 @@ export default function Login() {
password: 'fc6e39e3',
remember: true })
const title = 'ReviewFlow'
const title = 'Review Flow'
// Fetch Pexels image/video
useEffect( () => {

View File

@ -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(() => {

View 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>;
};

View File

@ -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(() => {