Autosave: 20260629-060213

This commit is contained in:
Flatlogic Bot 2026-06-29 06:02:08 +00:00
parent e4186ae090
commit df9c6cb725
17 changed files with 1915 additions and 154 deletions

View File

@ -0,0 +1,97 @@
'use strict';
const businessColumns = {
shopify_store_reference: { type: 'TEXT' },
shopify_connected: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
shopify_connected_at: { type: 'DATE' },
shopify_webhook_token: { type: 'TEXT' },
woocommerce_store_reference: { type: 'TEXT' },
woocommerce_connected: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
woocommerce_connected_at: { type: 'DATE' },
woocommerce_webhook_token: { type: 'TEXT' },
};
const customerColumns = {
shopify_customer_reference: { type: 'TEXT' },
woocommerce_customer_reference: { type: 'TEXT' },
};
const transactionColumns = {
shopify_order_reference: { type: 'TEXT' },
woocommerce_order_reference: { 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;
}
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 transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
async down(queryInterface) {
const transaction = await queryInterface.sequelize.transaction();
try {
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

@ -0,0 +1,96 @@
'use strict';
const businessColumns = {
review_destination: { type: 'TEXT' },
trustpilot_review_link: { type: 'TEXT' },
angi_review_link: { type: 'TEXT' },
opentable_review_link: { type: 'TEXT' },
shopify_hosted_reviews_enabled: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
};
const reviewRequestColumns = {
review_platform: { type: 'TEXT' },
review_rating: { type: 'INTEGER' },
review_title: { type: 'TEXT' },
review_content: { type: 'TEXT' },
reviewer_display_name: { type: 'TEXT' },
review_payload_json: { type: 'TEXT' },
submitted_at: { type: 'DATE' },
};
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 === 'INTEGER') {
normalized.type = Sequelize.DataTypes.INTEGER;
}
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, 'review_requests', reviewRequestColumns);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
async down(queryInterface) {
const transaction = await queryInterface.sequelize.transaction();
try {
await removeColumnsIfPresent(queryInterface, transaction, 'review_requests', reviewRequestColumns);
await removeColumnsIfPresent(queryInterface, transaction, 'businesses', businessColumns);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
};

View File

@ -158,6 +158,69 @@ paypal_webhook_token: {
},
shopify_store_reference: {
type: DataTypes.TEXT,
},
shopify_connected: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
shopify_connected_at: {
type: DataTypes.DATE,
},
shopify_webhook_token: {
type: DataTypes.TEXT,
},
woocommerce_store_reference: {
type: DataTypes.TEXT,
},
woocommerce_connected: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
woocommerce_connected_at: {
type: DataTypes.DATE,
},
woocommerce_webhook_token: {
type: DataTypes.TEXT,
},
default_review_platform: {
@ -187,6 +250,44 @@ custom_review_link: {
},
review_destination: {
type: DataTypes.TEXT,
},
trustpilot_review_link: {
type: DataTypes.TEXT,
},
angi_review_link: {
type: DataTypes.TEXT,
},
opentable_review_link: {
type: DataTypes.TEXT,
},
shopify_hosted_reviews_enabled: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
importHash: {

View File

@ -48,6 +48,21 @@ paypal_customer_reference: {
},
shopify_customer_reference: {
type: DataTypes.TEXT,
},
woocommerce_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 review_requests = sequelize.define(
'review_requests',
@ -113,6 +107,55 @@ tracking_token: {
},
review_platform: {
type: DataTypes.TEXT,
},
review_rating: {
type: DataTypes.INTEGER,
},
review_title: {
type: DataTypes.TEXT,
},
review_content: {
type: DataTypes.TEXT,
},
reviewer_display_name: {
type: DataTypes.TEXT,
},
review_payload_json: {
type: DataTypes.TEXT,
},
submitted_at: {
type: DataTypes.DATE,
},
importHash: {

View File

@ -34,6 +34,21 @@ paypal_payment_reference: {
},
shopify_order_reference: {
type: DataTypes.TEXT,
},
woocommerce_order_reference: {
type: DataTypes.TEXT,
},
provider_event_reference: {

View File

@ -36,6 +36,7 @@ const review_requestsRoutes = require('./routes/review_requests');
const reviewflowRoutes = require('./routes/reviewflow');
const reviewflowWebhooksRoutes = require('./routes/reviewflow-webhooks');
const reviewflowPublicRoutes = require('./routes/reviewflow-public');
const stripe_eventsRoutes = require('./routes/stripe_events');
@ -119,6 +120,8 @@ app.use('/api/reviewflow', passport.authenticate('jwt', {session: false}), revie
app.use('/api/reviewflow-webhooks', reviewflowWebhooksRoutes);
app.use('/api/reviewflow-public', reviewflowPublicRoutes);
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,24 @@
const express = require('express');
const ReviewFlowService = require('../services/reviewflow');
const wrapAsync = require('../helpers').wrapAsync;
const router = express.Router();
router.get('/reviews/:trackingToken', wrapAsync(async (req, res) => {
const review = await ReviewFlowService.getHostedReviewRequest(req.params.trackingToken);
res.status(200).send({ review });
}));
router.post('/reviews/:trackingToken', wrapAsync(async (req, res) => {
const review = await ReviewFlowService.submitHostedReview(
req.params.trackingToken,
req.body || {},
);
res.status(200).send({ review });
}));
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;

View File

@ -10,6 +10,7 @@ router.post('/:provider/:businessId/:secretToken', wrapAsync(async (req, res) =>
req.params.businessId,
req.params.secretToken,
req.body,
req.headers,
);
res.status(200).send({ received: true, ...result });

View File

@ -33,6 +33,30 @@ function validateUrl(value, message) {
}
}
const REVIEW_LINK_FIELDS = {
google: 'google_review_link',
yelp: 'yelp_review_link',
facebook: 'facebook_review_link',
trustpilot: 'trustpilot_review_link',
angi: 'angi_review_link',
opentable: 'opentable_review_link',
custom: 'custom_review_link',
};
function normalizeReviewDestination(value) {
const destination = normalizeString(value).toLowerCase();
if (ReviewFlowService.REVIEW_CHANNELS[destination]) {
return destination;
}
return 'google';
}
function getReviewLinkField(reviewDestination) {
return REVIEW_LINK_FIELDS[reviewDestination] || null;
}
function buildEmailBody(customerName, businessName, reviewLink) {
const greetingName = customerName || 'there';
return [
@ -48,6 +72,11 @@ function buildEmailBody(customerName, businessName, reviewLink) {
}
router.get('/review-channels', wrapAsync(async (req, res) => {
res.status(200).send({ channels: ReviewFlowService.serializeReviewChannels() });
}));
router.get('/connectors', wrapAsync(async (req, res) => {
const businesses = await ReviewFlowService.listConnectorBusinesses(req.currentUser, req);
@ -136,13 +165,18 @@ router.post('/request', wrapAsync(async (req, res) => {
const body = req.body || {};
const businessName = normalizeString(body.businessName);
const reviewLink = normalizeString(body.reviewLink);
const reviewDestination = normalizeReviewDestination(body.reviewDestination || body.reviewPlatform || 'google');
const isHostedReviewDestination = reviewDestination === 'shopify_hosted';
const reviewLinkField = getReviewLinkField(reviewDestination);
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.');
if (!isHostedReviewDestination) {
requireField(reviewLink, 'Review link is required.');
}
requireField(customerEmail, 'Customer email is required.');
if (!EMAIL_PATTERN.test(customerEmail)) {
@ -151,33 +185,53 @@ router.post('/request', wrapAsync(async (req, res) => {
throw error;
}
validateUrl(reviewLink, 'Enter a valid Google, Yelp, Facebook, or review page URL.');
if (reviewLink) {
validateUrl(reviewLink, 'Enter a valid review destination URL.');
}
const scheduledFor = new Date(Date.now() + delayDays * 24 * 60 * 60 * 1000);
const trackingToken = crypto.randomBytes(18).toString('hex');
const effectiveReviewLink = isHostedReviewDestination
? ReviewFlowService.getHostedReviewUrl(req, trackingToken)
: reviewLink;
const transaction = await db.sequelize.transaction();
try {
const businessDefaults = {
name: businessName,
review_destination: reviewDestination,
shopify_hosted_reviews_enabled: isHostedReviewDestination,
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,
};
if (reviewLink && reviewLinkField) {
businessDefaults[reviewLinkField] = reviewLink;
}
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,
},
defaults: businessDefaults,
transaction,
});
await business.update({
google_review_link: reviewLink,
const businessUpdates = {
review_destination: reviewDestination,
shopify_hosted_reviews_enabled: business.shopify_hosted_reviews_enabled || isHostedReviewDestination,
delay_days: delayDays,
is_active: true,
updatedById: currentUser.id,
}, { transaction });
};
if (reviewLink && reviewLinkField) {
businessUpdates[reviewLinkField] = reviewLink;
}
await business.update(businessUpdates, { transaction });
const [customer] = await db.customers.findOrCreate({
where: { email: customerEmail, createdById: currentUser.id },
@ -206,9 +260,10 @@ router.post('/request', wrapAsync(async (req, res) => {
status: 'pending',
scheduled_for: scheduledFor,
email_subject: emailSubject,
email_body: buildEmailBody(customerName, businessName, reviewLink),
review_link: reviewLink,
tracking_token: crypto.randomBytes(18).toString('hex'),
email_body: buildEmailBody(customerName, businessName, effectiveReviewLink),
review_link: effectiveReviewLink,
tracking_token: trackingToken,
review_platform: reviewDestination,
businessId: business.id,
customerId: customer.id,
createdById: currentUser.id,

View File

@ -24,6 +24,8 @@ const ZERO_DECIMAL_CURRENCIES = new Set([
const PROVIDERS = {
stripe: {
label: 'Stripe',
category: 'payment_trigger',
defaultReviewDestination: 'google',
accountField: 'stripe_account_reference',
connectedField: 'stripe_connected',
connectedAtField: 'stripe_connected_at',
@ -33,6 +35,8 @@ const PROVIDERS = {
},
square: {
label: 'Square',
category: 'payment_trigger',
defaultReviewDestination: 'google',
accountField: 'square_account_reference',
connectedField: 'square_connected',
connectedAtField: 'square_connected_at',
@ -42,6 +46,8 @@ const PROVIDERS = {
},
paypal: {
label: 'PayPal',
category: 'payment_trigger',
defaultReviewDestination: 'google',
accountField: 'paypal_merchant_reference',
connectedField: 'paypal_connected',
connectedAtField: 'paypal_connected_at',
@ -49,6 +55,104 @@ const PROVIDERS = {
paymentReferenceField: 'paypal_payment_reference',
customerReferenceField: 'paypal_customer_reference',
},
shopify: {
label: 'Shopify',
category: 'ecommerce_order_trigger',
defaultReviewDestination: 'shopify_hosted',
hostedReviewProvider: true,
accountField: 'shopify_store_reference',
connectedField: 'shopify_connected',
connectedAtField: 'shopify_connected_at',
tokenField: 'shopify_webhook_token',
paymentReferenceField: 'shopify_order_reference',
customerReferenceField: 'shopify_customer_reference',
},
woocommerce: {
label: 'WooCommerce',
category: 'ecommerce_order_trigger',
defaultReviewDestination: 'trustpilot',
accountField: 'woocommerce_store_reference',
connectedField: 'woocommerce_connected',
connectedAtField: 'woocommerce_connected_at',
tokenField: 'woocommerce_webhook_token',
paymentReferenceField: 'woocommerce_order_reference',
customerReferenceField: 'woocommerce_customer_reference',
},
};
const REVIEW_CHANNELS = {
google: {
key: 'google',
label: 'Google',
category: 'local_review_destination',
mode: 'external_link',
linkField: 'google_review_link',
requiresExternalLink: true,
helperText: 'Send local customers to the Google review link for the business profile.',
},
facebook: {
key: 'facebook',
label: 'Facebook',
category: 'local_review_destination',
mode: 'external_link',
linkField: 'facebook_review_link',
requiresExternalLink: true,
helperText: 'Send local customers to the Facebook recommendations/reviews link.',
},
yelp: {
key: 'yelp',
label: 'Yelp',
category: 'local_review_destination',
mode: 'external_link',
linkField: 'yelp_review_link',
requiresExternalLink: true,
helperText: 'Send local-service customers to the Yelp business review link.',
},
angi: {
key: 'angi',
label: 'Angi',
category: 'local_service_review_destination',
mode: 'external_link',
linkField: 'angi_review_link',
requiresExternalLink: true,
helperText: 'Send home-service customers to the Angi profile/review link.',
},
opentable: {
key: 'opentable',
label: 'OpenTable',
category: 'local_hospitality_review_destination',
mode: 'external_link',
linkField: 'opentable_review_link',
requiresExternalLink: true,
helperText: 'Send restaurant guests to the OpenTable restaurant/review link.',
},
trustpilot: {
key: 'trustpilot',
label: 'Trustpilot',
category: 'ecommerce_review_destination',
mode: 'external_link',
linkField: 'trustpilot_review_link',
requiresExternalLink: true,
helperText: 'Send ecommerce customers to the Trustpilot review invitation or business link.',
},
shopify_hosted: {
key: 'shopify_hosted',
label: 'Shopify hosted product review',
category: 'ecommerce_review_destination',
mode: 'hosted_form',
linkField: null,
requiresExternalLink: false,
helperText: 'Use Review Flows hosted product-review form after a Shopify paid order.',
},
custom: {
key: 'custom',
label: 'Custom review page',
category: 'custom_review_destination',
mode: 'external_link',
linkField: 'custom_review_link',
requiresExternalLink: true,
helperText: 'Send customers to a custom review page you control.',
},
};
function normalizeString(value) {
@ -59,6 +163,50 @@ function normalizeEmail(value) {
return normalizeString(value).toLowerCase();
}
function normalizeHeaderValue(value) {
if (Array.isArray(value)) {
return normalizeString(value[0]);
}
return normalizeString(value);
}
function getNormalizedReviewDestination(value) {
const normalizedDestination = normalizeString(value).toLowerCase();
if (REVIEW_CHANNELS[normalizedDestination]) {
return normalizedDestination;
}
return 'google';
}
function getReviewChannel(destination) {
return REVIEW_CHANNELS[getNormalizedReviewDestination(destination)];
}
function getReviewLinkField(destination) {
return getReviewChannel(destination).linkField;
}
function getReviewDestination(business) {
return getNormalizedReviewDestination(
business?.review_destination || business?.default_review_platform || 'google',
);
}
function getReviewDestinationForPayment(payment, business) {
if (payment?.provider === 'shopify' && business?.shopify_hosted_reviews_enabled !== false) {
return 'shopify_hosted';
}
return getReviewDestination(business);
}
function serializeReviewChannels() {
return Object.values(REVIEW_CHANNELS);
}
function httpError(message, code) {
const error = new Error(message);
error.code = code;
@ -87,7 +235,7 @@ function getProviderConfig(provider) {
const config = PROVIDERS[normalizedProvider];
if (!config) {
throw httpError('Unsupported payment provider. Use stripe, square, or paypal.', 400);
throw httpError('Unsupported webhook provider. Use stripe, square, paypal, shopify, or woocommerce.', 400);
}
return { provider: normalizedProvider, ...config };
@ -102,13 +250,43 @@ function getOwnerId(business) {
}
function getRequestOrigin(req) {
const forwardedProto = normalizeString(req.headers['x-forwarded-proto']).split(',')[0];
const forwardedProto = normalizeHeaderValue(req.headers['x-forwarded-proto']).split(',')[0];
const proto = forwardedProto || req.protocol || 'https';
const host = req.get('host');
const forwardedHost = normalizeHeaderValue(req.headers['x-forwarded-host']).split(',')[0];
const host = forwardedHost || req.get('host');
return `${proto}://${host}`;
}
function getRequestOriginFromHeaders(headers = {}) {
const forwardedProto = normalizeHeaderValue(headers['x-forwarded-proto']).split(',')[0];
const proto = forwardedProto || 'https';
const forwardedHost = normalizeHeaderValue(headers['x-forwarded-host']).split(',')[0];
const host = forwardedHost || normalizeHeaderValue(headers.host);
return host ? `${proto}://${host}` : '';
}
function getHostedReviewUrlFromOrigin(origin, trackingToken) {
if (!trackingToken) {
return '';
}
if (!origin) {
return `/review/${trackingToken}`;
}
return `${origin}/review/${trackingToken}`;
}
function getHostedReviewUrl(req, trackingToken) {
return getHostedReviewUrlFromOrigin(getRequestOrigin(req), trackingToken);
}
function getHostedReviewUrlFromHeaders(headers, trackingToken) {
return getHostedReviewUrlFromOrigin(getRequestOriginFromHeaders(headers), trackingToken);
}
function getWebhookUrl(req, business, provider) {
const config = getProviderConfig(provider);
const token = business[config.tokenField];
@ -120,22 +298,27 @@ function getWebhookUrl(req, business, provider) {
return `${getRequestOrigin(req)}/api/reviewflow-webhooks/${config.provider}/${business.id}/${token}`;
}
function getReviewLink(business) {
const platform = business.default_review_platform || 'google';
function getReviewLink(business, options = {}) {
const destination = getNormalizedReviewDestination(
options.reviewDestination || getReviewDestination(business),
);
const channel = getReviewChannel(destination);
if (platform === 'custom') {
return business.custom_review_link || business.google_review_link || business.yelp_review_link || business.facebook_review_link || '';
if (channel.mode === 'hosted_form') {
if (options.req) {
return getHostedReviewUrl(options.req, options.trackingToken);
}
return getHostedReviewUrlFromHeaders(options.headers, options.trackingToken);
}
if (platform === 'yelp') {
return business.yelp_review_link || business.google_review_link || business.facebook_review_link || business.custom_review_link || '';
const directLink = channel.linkField ? business[channel.linkField] : '';
if (directLink) {
return directLink;
}
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 || '';
return business.custom_review_link || business.google_review_link || business.yelp_review_link || business.facebook_review_link || business.trustpilot_review_link || business.angi_review_link || business.opentable_review_link || '';
}
function buildEmailBody(customerName, businessName, reviewLink) {
@ -305,6 +488,123 @@ function normalizePaypalEvent(payload) {
};
}
function normalizeShopifyEvent(payload, headers = {}) {
const eventType = normalizeString(
headers['x-shopify-topic'] ||
payload?.topic ||
payload?.event_type ||
payload?.type ||
'orders/paid',
);
const currency = normalizeString(
payload?.currency ||
payload?.presentment_currency ||
payload?.current_total_price_set?.shop_money?.currency_code ||
payload?.total_price_set?.shop_money?.currency_code ||
'',
).toUpperCase();
const customer = payload?.customer || {};
const billing = payload?.billing_address || {};
const shipping = payload?.shipping_address || {};
const financialStatus = normalizeString(payload?.financial_status).toLowerCase();
const orderReference = normalizeString(
payload?.admin_graphql_api_id ||
payload?.id ||
payload?.order_number ||
payload?.name,
);
const eventReference = normalizeString(
headers['x-shopify-webhook-id'] ||
payload?.webhook_id ||
payload?.id ||
`${eventType}-${orderReference}-${payload?.updated_at || payload?.created_at || ''}`,
);
const customerName = normalizeString(
[
customer.first_name || billing.first_name || shipping.first_name,
customer.last_name || billing.last_name || shipping.last_name,
].filter(Boolean).join(' '),
);
const products = Array.isArray(payload?.line_items)
? payload.line_items.map((item) => ({
name: normalizeString(item?.name || item?.title),
productId: normalizeString(item?.product_id ? String(item.product_id) : item?.admin_graphql_api_id),
variantId: normalizeString(item?.variant_id ? String(item.variant_id) : item?.variant_title),
sku: normalizeString(item?.sku),
quantity: Number(item?.quantity) || null,
})).filter((product) => product.name || product.productId || product.variantId).slice(0, 10)
: [];
return {
eventReference,
providerEventType: eventType,
normalizedEventType: normalizeEventType('shopify', eventType, payload),
paymentReference: orderReference,
amount: decimalAmount(
payload?.current_total_price ||
payload?.total_price ||
payload?.subtotal_price,
),
currency,
email: normalizeEmail(
payload?.email ||
payload?.contact_email ||
customer.email ||
billing.email,
),
customerName,
phone: normalizeString(payload?.phone || customer.phone || billing.phone || shipping.phone),
customerReference: normalizeString(customer.admin_graphql_api_id || customer.id || payload?.customer_id),
description: normalizeString(payload?.name || payload?.order_number || payload?.checkout_id || eventType),
paidAt: toDate(payload?.processed_at || payload?.created_at || payload?.updated_at),
reviewContext: {
provider: 'shopify',
order: {
id: orderReference,
name: normalizeString(payload?.name || payload?.order_number),
orderNumber: normalizeString(payload?.order_number ? String(payload.order_number) : ''),
},
products,
},
isPaymentSuccess: eventType === 'orders/paid' || financialStatus === 'paid',
};
}
function normalizeWooCommerceEvent(payload, headers = {}) {
const eventType = normalizeString(
headers['x-wc-webhook-topic'] ||
payload?.topic ||
payload?.event_type ||
payload?.type ||
'order.updated',
);
const billing = payload?.billing || {};
const orderStatus = normalizeString(payload?.status).toLowerCase();
const orderReference = normalizeString(payload?.id || payload?.order_key || payload?.number || payload?.transaction_id);
const eventReference = normalizeString(
headers['x-wc-webhook-delivery-id'] ||
headers['x-wc-webhook-id'] ||
payload?.webhook_id ||
`${eventType}-${orderReference}-${payload?.date_modified_gmt || payload?.date_modified || payload?.date_created || ''}`,
);
return {
eventReference,
providerEventType: eventType,
normalizedEventType: normalizeEventType('woocommerce', eventType, payload),
paymentReference: orderReference,
amount: decimalAmount(payload?.total),
currency: normalizeString(payload?.currency || '').toUpperCase(),
email: normalizeEmail(billing.email),
customerName: normalizeString([billing.first_name, billing.last_name].filter(Boolean).join(' ')),
phone: normalizeString(billing.phone),
customerReference: normalizeString(payload?.customer_id),
description: normalizeString(payload?.number || payload?.order_key || payload?.transaction_id || eventType),
paidAt: toDate(payload?.date_paid_gmt || payload?.date_paid || payload?.date_created_gmt || payload?.date_created),
isPaymentSuccess: ['processing', 'completed'].includes(orderStatus),
};
}
function normalizeEventType(provider, providerEventType, payment) {
const eventType = normalizeString(providerEventType);
const status = normalizeString(payment?.status).toLowerCase();
@ -328,10 +628,26 @@ function normalizeEventType(provider, providerEventType, payment) {
if (eventType.includes('COMPLETED')) return 'payment_intent_succeeded';
}
if (provider === 'shopify') {
const financialStatus = normalizeString(payment?.financial_status).toLowerCase();
if (financialStatus === 'refunded' || eventType.includes('refund')) return 'charge_refunded';
if (['voided', 'expired', 'declined'].includes(financialStatus)) return 'charge_failed';
if (eventType === 'orders/paid' || financialStatus === 'paid') return 'payment_intent_succeeded';
}
if (provider === 'woocommerce') {
const orderStatus = normalizeString(payment?.status).toLowerCase();
if (['refunded'].includes(orderStatus) || eventType.includes('refund')) return 'charge_refunded';
if (['failed', 'cancelled'].includes(orderStatus)) return 'charge_failed';
if (['processing', 'completed'].includes(orderStatus)) return 'payment_intent_succeeded';
}
return 'unknown';
}
function normalizePaymentEvent(provider, payload) {
function normalizePaymentEvent(provider, payload, headers = {}) {
if (provider === 'stripe') {
return normalizeStripeEvent(payload);
}
@ -340,7 +656,15 @@ function normalizePaymentEvent(provider, payload) {
return normalizeSquareEvent(payload);
}
return normalizePaypalEvent(payload);
if (provider === 'paypal') {
return normalizePaypalEvent(payload);
}
if (provider === 'shopify') {
return normalizeShopifyEvent(payload, headers);
}
return normalizeWooCommerceEvent(payload, headers);
}
function serializeBusiness(req, business) {
@ -350,8 +674,13 @@ function serializeBusiness(req, business) {
google_review_link: business.google_review_link,
yelp_review_link: business.yelp_review_link,
facebook_review_link: business.facebook_review_link,
trustpilot_review_link: business.trustpilot_review_link,
angi_review_link: business.angi_review_link,
opentable_review_link: business.opentable_review_link,
custom_review_link: business.custom_review_link,
default_review_platform: business.default_review_platform,
review_destination: getReviewDestination(business),
shopify_hosted_reviews_enabled: Boolean(business.shopify_hosted_reviews_enabled),
delay_days: business.delay_days,
providers: Object.keys(PROVIDERS).map((providerKey) => {
const config = getProviderConfig(providerKey);
@ -360,6 +689,9 @@ function serializeBusiness(req, business) {
return {
key: providerKey,
label: config.label,
category: config.category,
default_review_destination: config.defaultReviewDestination,
hosted_review_provider: Boolean(config.hostedReviewProvider),
connected: Boolean(business[config.connectedField]),
connected_at: business[config.connectedAtField] || null,
account_reference: business[config.accountField] || '',
@ -387,6 +719,10 @@ async function connectProvider(currentUser, body, req) {
const businessName = normalizeString(body.businessName);
const reviewLink = normalizeString(body.reviewLink);
const accountReference = normalizeString(body.accountReference);
const reviewDestination = getNormalizedReviewDestination(
body.reviewDestination || body.defaultReviewPlatform || body.reviewPlatform || config.defaultReviewDestination,
);
const reviewLinkField = getReviewLinkField(reviewDestination);
const delayDays = Math.max(0, Math.min(Number(body.delayDays) || 0, 30));
let business;
@ -406,9 +742,10 @@ async function connectProvider(currentUser, body, req) {
}
if (!business) {
business = await db.businesses.create({
const createPayload = {
name: businessName,
google_review_link: reviewLink || null,
review_destination: reviewDestination,
shopify_hosted_reviews_enabled: Boolean(config.hostedReviewProvider || reviewDestination === 'shopify_hosted'),
delay_days: delayDays,
email_subject_template: `How was your experience with ${businessName}?`,
email_body_template: buildEmailBody('{customerName}', businessName, '{reviewLink}'),
@ -416,12 +753,22 @@ async function connectProvider(currentUser, body, req) {
createdById: currentUser.id,
updatedById: currentUser.id,
ownerId: currentUser.id,
});
};
if (reviewLink && reviewLinkField) {
createPayload[reviewLinkField] = reviewLink;
}
business = await db.businesses.create(createPayload);
}
const updates = {
is_active: true,
delay_days: delayDays,
review_destination: reviewDestination,
shopify_hosted_reviews_enabled: Boolean(
business.shopify_hosted_reviews_enabled || config.hostedReviewProvider || reviewDestination === 'shopify_hosted',
),
updatedById: currentUser.id,
[config.connectedField]: true,
[config.connectedAtField]: new Date(),
@ -433,8 +780,8 @@ async function connectProvider(currentUser, body, req) {
updates.name = businessName;
}
if (reviewLink) {
updates.google_review_link = reviewLink;
if (reviewLink && reviewLinkField) {
updates[reviewLinkField] = reviewLink;
}
await business.update(updates);
@ -521,6 +868,8 @@ async function createTransactionFromPayment(payment, business, customer, transac
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,
shopify_order_reference: payment.provider === 'shopify' ? payment.paymentReference || null : null,
woocommerce_order_reference: payment.provider === 'woocommerce' ? payment.paymentReference || null : null,
provider_event_reference: payment.eventReference || null,
amount: payment.amount,
currency: payment.currency || null,
@ -537,9 +886,24 @@ async function createTransactionFromPayment(payment, business, customer, transac
return { transactionRecord, duplicate: false };
}
async function createReviewRequestFromPayment(payment, business, customer, transactionRecord, transaction) {
function safeJsonStringify(value) {
if (!value) {
return null;
}
return JSON.stringify(value);
}
async function createReviewRequestFromPayment(payment, business, customer, transactionRecord, transaction, headers = {}) {
const ownerId = getOwnerId(business);
const reviewLink = getReviewLink(business);
const trackingToken = crypto.randomBytes(18).toString('hex');
const reviewDestination = getReviewDestinationForPayment(payment, business);
const reviewLink = getReviewLink(business, {
headers,
provider: payment.provider,
reviewDestination,
trackingToken,
});
if (!payment.isPaymentSuccess) {
return null;
@ -570,7 +934,9 @@ async function createReviewRequestFromPayment(payment, business, customer, trans
email_subject: emailSubject,
email_body: emailBody,
review_link: reviewLink,
tracking_token: crypto.randomBytes(18).toString('hex'),
tracking_token: trackingToken,
review_platform: reviewDestination,
review_payload_json: safeJsonStringify(payment.reviewContext),
businessId: business.id,
customerId: customer.id,
transactionId: transactionRecord.id,
@ -579,7 +945,7 @@ async function createReviewRequestFromPayment(payment, business, customer, trans
}, { transaction });
}
async function processPaymentWebhook(providerName, businessId, secretToken, payload) {
async function processPaymentWebhook(providerName, businessId, secretToken, payload, headers = {}) {
const { provider, ...config } = getProviderConfig(providerName);
const business = await db.businesses.findByPk(businessId);
@ -593,7 +959,7 @@ async function processPaymentWebhook(providerName, businessId, secretToken, payl
const payment = {
provider,
...normalizePaymentEvent(provider, payload || {}),
...normalizePaymentEvent(provider, payload || {}, headers),
};
const ownerId = getOwnerId(business);
const existingEvent = payment.eventReference ? await db.stripe_events.findOne({
@ -642,6 +1008,7 @@ async function processPaymentWebhook(providerName, businessId, secretToken, payl
customer,
transactionRecord,
transaction,
headers,
);
let processingError = null;
@ -683,15 +1050,141 @@ async function processPaymentWebhook(providerName, businessId, secretToken, payl
}
}
function parseReviewPayloadJson(value) {
if (!value) {
return null;
}
try {
return JSON.parse(value);
} catch (error) {
console.error('Failed to parse hosted review payload JSON:', error);
return null;
}
}
function serializeHostedReviewRequest(reviewRequest) {
const reviewPayload = parseReviewPayloadJson(reviewRequest.review_payload_json);
return {
id: reviewRequest.id,
status: reviewRequest.status,
scheduled_for: reviewRequest.scheduled_for,
reviewed_at: reviewRequest.reviewed_at,
submitted_at: reviewRequest.submitted_at,
review_platform: reviewRequest.review_platform,
review_rating: reviewRequest.review_rating,
review_title: reviewRequest.review_title,
review_content: reviewRequest.review_content,
reviewer_display_name: reviewRequest.reviewer_display_name,
review_payload: reviewPayload,
business: reviewRequest.business ? {
id: reviewRequest.business.id,
name: reviewRequest.business.name,
} : null,
customer: reviewRequest.customer ? {
name: reviewRequest.customer.name,
email: reviewRequest.customer.email,
} : null,
transaction: reviewRequest.transaction ? {
id: reviewRequest.transaction.id,
payment_provider: reviewRequest.transaction.payment_provider,
amount: reviewRequest.transaction.amount,
currency: reviewRequest.transaction.currency,
paid_at: reviewRequest.transaction.paid_at,
description: reviewRequest.transaction.description,
} : null,
};
}
async function findHostedReviewRequest(trackingToken) {
const token = normalizeString(trackingToken);
requireField(token, 'Review token is required.');
const reviewRequest = await db.review_requests.findOne({
where: { tracking_token: token },
include: [
{ model: db.businesses, as: 'business' },
{ model: db.customers, as: 'customer' },
{ model: db.transactions, as: 'transaction' },
],
});
if (!reviewRequest) {
throw httpError('Review request was not found or has expired.', 404);
}
return reviewRequest;
}
async function getHostedReviewRequest(trackingToken) {
const reviewRequest = await findHostedReviewRequest(trackingToken);
if (!reviewRequest.clicked_at && reviewRequest.status !== 'reviewed') {
await reviewRequest.update({
clicked_at: new Date(),
status: ['pending', 'sent', 'opened'].includes(reviewRequest.status) ? 'clicked' : reviewRequest.status,
});
}
return serializeHostedReviewRequest(reviewRequest);
}
async function submitHostedReview(trackingToken, body) {
const reviewRequest = await findHostedReviewRequest(trackingToken);
const rating = Number(body?.rating ?? body?.review_rating);
const reviewTitle = normalizeString(body?.title || body?.review_title).slice(0, 200);
const reviewContent = normalizeString(body?.content || body?.review_content).slice(0, 5000);
const reviewerDisplayName = normalizeString(body?.reviewerName || body?.reviewer_display_name).slice(0, 120);
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
throw httpError('Choose a rating from 1 to 5 stars.', 400);
}
if (!reviewContent) {
throw httpError('Review text is required.', 400);
}
const reviewPayload = parseReviewPayloadJson(reviewRequest.review_payload_json) || {};
reviewPayload.hostedReview = {
submittedAt: new Date().toISOString(),
source: 'hosted_review_form',
};
await reviewRequest.update({
status: 'reviewed',
clicked_at: reviewRequest.clicked_at || new Date(),
reviewed_at: new Date(),
submitted_at: new Date(),
review_rating: rating,
review_title: reviewTitle || null,
review_content: reviewContent,
reviewer_display_name: reviewerDisplayName || reviewRequest.customer?.name || null,
review_payload_json: safeJsonStringify(reviewPayload),
});
const refreshedReviewRequest = await findHostedReviewRequest(trackingToken);
return serializeHostedReviewRequest(refreshedReviewRequest);
}
module.exports = {
PROVIDERS,
REVIEW_CHANNELS,
buildEmailBody,
connectProvider,
generateWebhookToken,
getHostedReviewRequest,
getHostedReviewUrl,
getProviderConfig,
getReviewDestination,
getReviewLink,
getWebhookUrl,
listConnectorBusinesses,
processPaymentWebhook,
rotateWebhookToken,
serializeBusiness,
serializeReviewChannels,
submitHostedReview,
};

View File

@ -13,8 +13,11 @@ import CardBox from '../CardBox';
import FormField from '../FormField';
export interface ProviderConnector {
key: 'stripe' | 'square' | 'paypal' | string;
key: 'stripe' | 'square' | 'paypal' | 'shopify' | 'woocommerce' | string;
label: string;
category?: string;
default_review_destination?: string;
hosted_review_provider?: boolean;
connected: boolean;
connected_at?: string | null;
account_reference?: string;
@ -27,6 +30,14 @@ export interface ConnectorBusiness {
id: string;
name?: string;
google_review_link?: string;
yelp_review_link?: string;
facebook_review_link?: string;
trustpilot_review_link?: string;
angi_review_link?: string;
opentable_review_link?: string;
custom_review_link?: string;
review_destination?: string;
shopify_hosted_reviews_enabled?: boolean;
delay_days?: number;
providers: ProviderConnector[];
}
@ -34,6 +45,7 @@ export interface ConnectorBusiness {
export interface ConnectorFormValues {
provider: string;
businessName: string;
reviewDestination: string;
reviewLink: string;
delayDays: string;
accountReference: string;
@ -53,6 +65,7 @@ interface PaymentProviderConnectorsProps {
const connectorDefaults: ConnectorFormValues = {
provider: 'stripe',
businessName: 'Review Flow Studio',
reviewDestination: 'google',
reviewLink: 'https://g.page/r/example/review',
delayDays: '7',
accountReference: '',
@ -62,18 +75,110 @@ const providerOptions = [
{
key: 'stripe',
label: 'Stripe',
categoryLabel: 'Payment trigger',
defaultReviewDestination: 'google',
description: 'Connect card and checkout payments from Stripe.',
},
{
key: 'paypal',
label: 'PayPal',
categoryLabel: 'Payment trigger',
defaultReviewDestination: 'google',
description: 'Connect completed PayPal captures and sales.',
},
{
key: 'square',
label: 'Square',
categoryLabel: 'Payment trigger',
defaultReviewDestination: 'google',
description: 'Connect Square payment notifications.',
},
{
key: 'shopify',
label: 'Shopify',
categoryLabel: 'Ecommerce order trigger + hosted reviews',
defaultReviewDestination: 'shopify_hosted',
description: 'Connect paid Shopify orders; customers review products on a hosted Review Flow form.',
},
{
key: 'woocommerce',
label: 'WooCommerce',
categoryLabel: 'Ecommerce order trigger',
defaultReviewDestination: 'trustpilot',
description: 'Connect WooCommerce orders from your WordPress store.',
},
];
const reviewDestinationOptions = [
{
key: 'google',
label: 'Google',
group: 'Local review destinations',
mode: 'external_link',
description: 'For local businesses collecting Google profile reviews.',
},
{
key: 'facebook',
label: 'Facebook',
group: 'Local review destinations',
mode: 'external_link',
description: 'For local Facebook recommendations and reviews.',
},
{
key: 'yelp',
label: 'Yelp',
group: 'Local review destinations',
mode: 'external_link',
description: 'For local-service Yelp review requests.',
},
{
key: 'angi',
label: 'Angi',
group: 'Local review destinations',
mode: 'external_link',
description: 'For home-service Angi profile review requests.',
},
{
key: 'opentable',
label: 'OpenTable',
group: 'Local review destinations',
mode: 'external_link',
description: 'For restaurant guests leaving OpenTable reviews.',
},
{
key: 'shopify_hosted',
label: 'Shopify hosted product review',
group: 'Ecommerce review destinations',
mode: 'hosted_form',
description: 'Review Flow hosts the product review form after a Shopify paid order.',
},
{
key: 'trustpilot',
label: 'Trustpilot',
group: 'Ecommerce review destinations',
mode: 'external_link',
description: 'For ecommerce brand/store review invitations.',
},
{
key: 'custom',
label: 'Custom review page',
group: 'Custom destination',
mode: 'external_link',
description: 'Use any review page you control.',
},
];
const reviewDestinationGroups = [
{
title: 'Local review destinations',
subtitle: 'Google, Facebook, Yelp, Angi, and OpenTable send customers to the correct local profile/review page.',
keys: ['google', 'facebook', 'yelp', 'angi', 'opentable'],
},
{
title: 'Ecommerce review destinations',
subtitle: 'Shopify uses Review Flow hosted product reviews. Trustpilot stays separate as an ecommerce review link destination.',
keys: ['shopify_hosted', 'trustpilot'],
},
];
const providerInstructions: Record<string, string[]> = {
@ -92,6 +197,16 @@ const providerInstructions: Record<string, string[]> = {
'Paste this Review Flow webhook URL as the webhook URL.',
'Send PAYMENT.CAPTURE.COMPLETED or PAYMENT.SALE.COMPLETED events.',
],
shopify: [
'Shopify admin → Settings → Notifications → Webhooks → Create webhook.',
'Choose JSON format, paste this Review Flow webhook URL, and save the webhook.',
'Start with orders/paid. Review Flow will generate a hosted product-review page for each paid order.',
],
woocommerce: [
'WordPress admin → WooCommerce → Settings → Advanced → Webhooks → Add webhook.',
'Set status to Active, paste this Review Flow webhook URL as the delivery URL, and save.',
'Create order.created and order.updated webhooks so paid status changes can queue reviews.',
],
};
const providerSetupDetails: Record<
@ -156,14 +271,54 @@ const providerSetupDetails: Record<
testTip:
'A completed PayPal capture, sale, or checkout order should appear as a payment event before a review request is queued.',
},
shopify: {
dashboardPath:
'Shopify admin → Settings → Notifications → Webhooks → Create webhook',
requiredEvents: ['orders/paid', 'orders/create', 'orders/fulfilled'],
steps: [
'Select Shopify in Review Flow, enter the business name and delay days, and click Connect Shopify.',
'Copy the generated Review Flow webhook URL from the connected account card.',
'In Shopify admin, open Settings, then Notifications, then create a webhook in the Webhooks section.',
'Choose the Order payment / orders/paid event, select JSON format, paste the copied URL, and save the webhook.',
'Review Flow will create a customer, transaction, and hosted product-review form link for each paid order with an email.',
'Use Shopify test notifications or place a test paid order, then return here and click Refresh connectors.',
],
testTip:
'A paid Shopify order queues a hosted Review Flow product review when the payload includes a customer email and financial_status is paid.',
},
woocommerce: {
dashboardPath:
'WordPress admin → WooCommerce → Settings → Advanced → Webhooks → Add webhook',
requiredEvents: ['order.created', 'order.updated'],
steps: [
'Select WooCommerce in Review Flow, enter the business name, review link, delay days, and click Connect WooCommerce.',
'Copy the generated Review Flow webhook URL from the connected account card.',
'In WordPress admin, open WooCommerce, then Settings, then Advanced, then Webhooks, and click Add webhook.',
'Name the webhook Review Flow, set Status to Active, choose the Order created topic, and paste the copied URL into Delivery URL.',
'Save the webhook, then add a second Active webhook for Order updated so status changes to processing or completed are captured.',
'Create or update a test order to processing/completed, then return here and click Refresh connectors.',
],
testTip:
'A WooCommerce order queues a review when status is processing or completed and the billing email is present.',
},
};
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',
shopify: 'from-lime-600 to-emerald-700',
woocommerce: 'from-purple-700 to-fuchsia-700',
};
function getProviderCardTitle(provider: ProviderConnector) {
if (provider.key === 'shopify' || provider.hosted_review_provider) {
return 'Order trigger + hosted review form';
}
return 'Webhook receiver';
}
function formatDate(value?: string | null) {
if (!value) return 'Not scheduled';
@ -188,9 +343,9 @@ function isUnauthorizedError(error: unknown) {
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.',
eyebrow = 'Order triggers and review destinations',
title = 'Connect payment/ecommerce triggers without mixing local review channels',
description = 'Payment and ecommerce providers trigger review requests. Review destinations decide where customers leave feedback: local profiles, ecommerce review links, or the hosted Shopify product-review form.',
onConnected,
}: PaymentProviderConnectorsProps) {
const [connectorForm, setConnectorForm] =
@ -207,6 +362,16 @@ export default function PaymentProviderConnectors({
providerOptions.find(
(provider) => provider.key === connectorForm.provider,
) || providerOptions[0];
const effectiveReviewDestination =
selectedProvider.defaultReviewDestination === 'shopify_hosted'
? 'shopify_hosted'
: connectorForm.reviewDestination;
const selectedReviewDestination =
reviewDestinationOptions.find(
(destination) => destination.key === effectiveReviewDestination,
) || reviewDestinationOptions[0];
const isHostedReviewDestination =
selectedReviewDestination.mode === 'hosted_form';
const connectorPreviewDate = useMemo(() => {
if (!isClientReady) return 'after the selected delay';
@ -238,6 +403,21 @@ export default function PaymentProviderConnectors({
setConnectorForm((current) => ({ ...current, [key]: value }));
};
const updateSelectedProvider = (providerKey: string) => {
const provider =
providerOptions.find((providerOption) => providerOption.key === providerKey) ||
providerOptions[0];
setConnectorForm((current) => ({
...current,
provider: provider.key,
reviewDestination:
provider.defaultReviewDestination === 'shopify_hosted'
? 'shopify_hosted'
: current.reviewDestination,
}));
};
const loadConnectors = async () => {
setIsConnectorLoading(true);
try {
@ -277,20 +457,25 @@ export default function PaymentProviderConnectors({
setError('');
try {
const response = await axios.post('/reviewflow/connectors', {
const submittedConnectorForm = {
...connectorForm,
reviewDestination: effectiveReviewDestination,
reviewLink: isHostedReviewDestination ? '' : connectorForm.reviewLink,
};
const response = await axios.post('/reviewflow/connectors', {
...submittedConnectorForm,
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.`,
`${selectedProvider.label} is connected for ${business.name}. ${selectedReviewDestination.label} is the review destination. Copy the secure webhook URL below into your ${selectedProvider.label} dashboard.`,
);
await loadConnectors();
if (onConnected) {
try {
await onConnected(business, connectorForm);
await onConnected(business, submittedConnectorForm);
} catch (refreshError) {
console.error(
'Payment connector post-connect refresh failed:',
@ -380,9 +565,10 @@ export default function PaymentProviderConnectors({
/>
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.
Payment and ecommerce providers POST order/payment events to a public webhook URL.
Local review destinations do not use these webhooks; they are the places customers
visit after a request. Shopify is the exception here: it triggers from orders and
Review Flow hosts the product-review form.
</div>
</div>
@ -397,7 +583,44 @@ export default function PaymentProviderConnectors({
</div>
)}
<div className='mb-6 grid gap-3 md:grid-cols-3'>
<div className='mb-6 grid gap-4 lg:grid-cols-2'>
{reviewDestinationGroups.map((group) => (
<div
key={group.title}
className='rounded-2xl border border-slate-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900'
>
<p className='text-xs font-bold uppercase tracking-[0.22em] text-slate-400'>
Review destination group
</p>
<h4 className='mt-1 text-lg font-black text-slate-900 dark:text-white'>
{group.title}
</h4>
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400'>
{group.subtitle}
</p>
<div className='mt-3 flex flex-wrap gap-2'>
{group.keys.map((destinationKey) => {
const destination = reviewDestinationOptions.find(
(option) => option.key === destinationKey,
);
if (!destination) return null;
return (
<span
key={destination.key}
className='rounded-full bg-slate-100 px-3 py-1 text-xs font-bold text-slate-700 dark:bg-dark-800 dark:text-slate-200'
>
{destination.label}
</span>
);
})}
</div>
</div>
))}
</div>
<div className='mb-6 grid gap-3 sm:grid-cols-2 xl:grid-cols-5'>
{providerOptions.map((provider) => {
const isSelected = connectorForm.provider === provider.key;
@ -405,7 +628,7 @@ export default function PaymentProviderConnectors({
<button
key={provider.key}
type='button'
onClick={() => updateConnectorForm('provider', provider.key)}
onClick={() => updateSelectedProvider(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'
@ -415,7 +638,10 @@ export default function PaymentProviderConnectors({
<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'>
<p className='text-xs font-bold uppercase tracking-[0.18em] text-slate-400'>
{provider.categoryLabel}
</p>
<p className='mt-1 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'>
@ -436,13 +662,13 @@ export default function PaymentProviderConnectors({
>
<select
value={connectorForm.provider}
onChange={(event) =>
updateConnectorForm('provider', event.target.value)
}
onChange={(event) => updateSelectedProvider(event.target.value)}
>
<option value='stripe'>Stripe</option>
<option value='paypal'>PayPal</option>
<option value='square'>Square</option>
{providerOptions.map((provider) => (
<option key={`${provider.key}-option`} value={provider.key}>
{provider.label}
</option>
))}
</select>
<input
required
@ -461,18 +687,22 @@ export default function PaymentProviderConnectors({
/>
</FormField>
<FormField
label='Review trigger settings'
help={`Successful payments will queue reviews for ${connectorPreviewDate}.`}
label='Review destination'
help={`${selectedProvider.label} events will queue ${selectedReviewDestination.label} requests for ${connectorPreviewDate}.`}
>
<input
required
type='url'
value={connectorForm.reviewLink}
<select
value={effectiveReviewDestination}
disabled={selectedProvider.defaultReviewDestination === 'shopify_hosted'}
onChange={(event) =>
updateConnectorForm('reviewLink', event.target.value)
updateConnectorForm('reviewDestination', event.target.value)
}
placeholder='https://g.page/.../review'
/>
>
{reviewDestinationOptions.map((destination) => (
<option key={`${destination.key}-destination`} value={destination.key}>
{destination.label}
</option>
))}
</select>
<input
min='0'
max='30'
@ -484,6 +714,27 @@ export default function PaymentProviderConnectors({
placeholder='Delay days'
/>
</FormField>
<FormField
label={isHostedReviewDestination ? 'Hosted review form' : 'External review link'}
help={selectedReviewDestination.description}
>
{isHostedReviewDestination ? (
<div className='rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-sm font-semibold text-emerald-900'>
No external Shopify review URL is needed. Review Flow generates a secure hosted
product-review page for each Shopify paid order.
</div>
) : (
<input
required
type='url'
value={connectorForm.reviewLink}
onChange={(event) =>
updateConnectorForm('reviewLink', event.target.value)
}
placeholder='https://your-review-destination.example/review'
/>
)}
</FormField>
<div className='flex flex-wrap gap-3'>
<BaseButton
type='submit'
@ -513,7 +764,7 @@ export default function PaymentProviderConnectors({
Installation guide
</p>
<h4 className='text-xl font-black text-slate-900 dark:text-white'>
How to install payment webhooks
How to install provider 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
@ -522,7 +773,7 @@ export default function PaymentProviderConnectors({
</p>
</div>
<div className='mt-5 grid gap-4 xl:grid-cols-3'>
<div className='mt-5 grid gap-4 lg:grid-cols-2 2xl:grid-cols-3'>
{providerOptions.map((provider) => {
const setup = providerSetupDetails[provider.key];
@ -592,25 +843,24 @@ export default function PaymentProviderConnectors({
Connected accounts
</p>
<h4 className='text-xl font-black text-slate-900 dark:text-white'>
{providerSummary.connectedCount} of {providerSummary.totalCount}{' '}
provider slots connected
{`${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...
Loading webhook 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
No order/payment triggers 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.
Choose Stripe, PayPal, Square, Shopify, or WooCommerce above to
generate your first secure order/payment webhook URL.
</p>
</div>
) : (
@ -626,7 +876,7 @@ export default function PaymentProviderConnectors({
{business.name}
</h4>
<p className='text-sm text-slate-500'>
Default review delay: {business.delay_days ?? 0} days
Default review delay: {business.delay_days ?? 0} days · Review destination: {business.review_destination || 'google'}
</p>
</div>
<BaseButton
@ -637,7 +887,7 @@ export default function PaymentProviderConnectors({
small
/>
</div>
<div className='grid gap-4 xl:grid-cols-3'>
<div className='grid gap-4 lg:grid-cols-2 2xl:grid-cols-3'>
{business.providers.map((provider) => (
<div
key={`${business.id}-${provider.key}`}
@ -652,7 +902,7 @@ export default function PaymentProviderConnectors({
{provider.label}
</p>
<h5 className='text-lg font-black'>
Webhook receiver
{getProviderCardTitle(provider)}
</h5>
</div>
<span
@ -706,7 +956,7 @@ export default function PaymentProviderConnectors({
</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
Provider setup steps
</p>
<ol className='list-decimal space-y-1 pl-4'>
{(providerInstructions[provider.key] || []).map(

View File

@ -28,16 +28,13 @@ export default function ConnectPage() {
<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
Trigger setup · destination clarity
</p>
<h2 className='max-w-3xl text-4xl font-black tracking-tight md:text-5xl'>
Connect Stripe, PayPal, and Square.
Connect order/payment triggers without confusing local review channels.
</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.
Use this page to generate secure webhook URLs for payment and ecommerce triggers. Review destinations stay separate: local businesses use Google, Facebook, Yelp, Angi, or OpenTable links, ecommerce brands can use Trustpilot, and Shopify can use a hosted product-review form.
</p>
</div>
<div className='rounded-3xl bg-white/10 p-5 ring-1 ring-white/15 backdrop-blur'>
@ -46,9 +43,7 @@ export default function ConnectPage() {
</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.
Pick the order/payment trigger first, then choose where reviews should land. Shopify is both an ecommerce trigger and a hosted Review Flow product-review destination.
</p>
</div>
</div>
@ -56,8 +51,8 @@ export default function ConnectPage() {
<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.'
title='Connect triggers and choose review destinations'
description='Choose Stripe, PayPal, Square, Shopify, or WooCommerce as the order/payment trigger. Then choose a separate review destination so local and ecommerce customers see the right experience.'
/>
</SectionMain>
</>

View File

@ -9,12 +9,12 @@ import { getPageTitle } from '../config';
const metrics = [
['7 days', 'default review delay'],
['3 sources', 'Stripe, Square, PayPal'],
['5 sources', 'Stripe, Square, PayPal, Shopify, WooCommerce'],
['4 states', 'pending, sent, clicked, reviewed'],
];
const steps = [
['Capture', 'Receive Stripe, Square, or PayPal payment webhooks as soon as checkout happens.'],
['Capture', 'Receive Stripe, Square, PayPal, Shopify, or WooCommerce 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.'],
];
@ -65,7 +65,7 @@ export default function Starter() {
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.
Review Flow turns Stripe, Square, PayPal, Shopify, and WooCommerce 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" />

View File

@ -20,7 +20,7 @@ import { findMe, loginUser, resetAction } from '../stores/authSlice';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
import Link from 'next/link';
import {toast, ToastContainer} from "react-toastify";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'
import { getPexelsImage } from '../helpers/pexels'
export default function Login() {
const router = useRouter();
@ -33,9 +33,7 @@ export default function Login() {
photographer: undefined,
photographer_url: undefined,
})
const [ illustrationVideo, setIllustrationVideo ] = useState({video_files: []})
const [contentType, setContentType] = useState('video');
const [contentPosition, setContentPosition] = useState('left');
const [contentPosition] = useState<'left' | 'right' | 'background'>('left');
const [showPassword, setShowPassword] = useState(false);
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector(
(state) => state.auth,
@ -46,13 +44,135 @@ export default function Login() {
const title = 'Review Flow'
// Fetch Pexels image/video
const appHighlights = [
'Automated review requests after payments, jobs, or service milestones.',
'Customer, business, transaction, and delivery follow-up data in one admin workspace.',
'Dashboards, CRM records, payment events, email logs, and admin controls already built in.',
];
const competitorAdvantages = [
{
title: 'Built around review operations',
description:
'Review Flow combines CRM records, payments, follow-up, review requests, and reputation workflows in one focused system.',
},
{
title: 'Designed for logistics teams',
description:
'Transportation teams can manage businesses, customers, transactions, payment events, and review requests without jumping tools.',
},
{
title: 'More value in the base plan',
description:
'The $65 Base plan includes review automation, widgets, social proof, analytics, AI replies, referrals, and the app tools already available.',
},
];
const pricingPlans = [
{
name: 'Base',
price: '$65',
description:
'Best for businesses that want full review growth tools plus the core Review Flow admin system.',
sections: [
{
title: 'Review automation',
features: [
'Automate review requests and follow-up reminders.',
'Manually send review requests.',
'Personalize review request SMS and email messaging.',
'Personalize review invite links.',
'Monitor reviews across the web.',
'New review notifications and opportunities reports.',
],
},
{
title: 'Widgets, referrals, and social proof',
features: [
'Showcase reviews on your website with social proof widgets.',
'Collect reviews and leads with widgets for your website.',
'Microsite that showcases your reviews and generates leads.',
'Automate sharing of reviews to your social media accounts.',
'Share referral link on social media.',
],
},
{
title: 'Insights, AI, and team motivation',
features: [
'Easily respond to customer reviews with AI-generated replies.',
'Gain review insights and trending topics.',
'Campaign insights and analytics.',
'Encourage friendly competition with staff leaderboards.',
'Connect to 1000s of business apps.',
],
},
{
title: 'Existing Review Flow tools included',
features: [
'Review Flow workspace for creating, scheduling, and tracking review requests.',
'Business, customer, transaction, and delivery follow-up records.',
'Webhook connectors for Stripe, PayPal, Square, Shopify, and WooCommerce workflows.',
'Payment events, email delivery logs, and cron run monitoring.',
'Admin dashboard with users, roles, permissions, profile, and API documentation access.',
],
},
],
},
{
name: 'Pro',
price: '$99',
description:
'Best for growing teams that want every Base feature plus booking, referral, gifting, competitor, and advanced AI tools.',
sections: [
{
title: 'Everything in Base',
features: [
'Includes all Base review automation, widgets, referrals, analytics, AI replies, social sharing, integrations, and existing app tools.',
'Advanced workflow management.',
'Priority setup support.',
],
},
{
title: 'Booking reminders',
features: [
'Automate repeat booking reminders and follow-ups.',
'Personalize booking reminder SMS and email messaging.',
],
},
{
title: 'Referral automation',
features: [
'Automate customer referral requests and follow-ups.',
'Personalize referral request SMS and email messaging.',
'Personalize referral invite links.',
],
},
{
title: 'Gifting and loyalty',
features: [
'Delight your loyal customers with gift automations.',
'Automate gifting for new customers.',
],
},
{
title: 'Competitor intelligence and advanced feedback',
features: [
'Gain competitor review and SEO insights.',
'Track competitor topics and gain valuable competitive intel.',
'Competitor topic insights include topics for your business.',
'Automate review replies with AI.',
'Collect deeper, more actionable customer feedback with NPS Surveys.',
],
},
],
},
];
// Fetch Pexels image
useEffect( () => {
async function fetchData() {
const image = await getPexelsImage()
const video = await getPexelsVideo()
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
@ -115,32 +235,7 @@ export default function Login() {
</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' ? {
@ -159,8 +254,7 @@ export default function Login() {
<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}
{contentPosition !== 'background' ? imageBlock(illustrationImage) : null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox id="loginRoles" className='w-full md:w-3/5 lg:w-2/3'>
@ -257,6 +351,95 @@ export default function Login() {
</Form>
</Formik>
</CardBox>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<div className='space-y-8'>
<div>
<p className='text-sm font-semibold uppercase tracking-[0.2em] text-blue-600'>About Us</p>
<h3 className='mt-2 text-3xl font-semibold text-gray-900 dark:text-white'>
Review management built for transportation teams.
</h3>
<p className='mt-4 text-base leading-7 text-gray-600 dark:text-slate-300'>
Review Flow helps logistics and transportation businesses turn completed jobs, payments,
and customer interactions into organized review requests. Your team can manage customer
records, monitor follow-up, and keep reputation-building work moving from one secure
admin panel.
</p>
</div>
<div className='grid gap-3 md:grid-cols-3'>
{appHighlights.map((highlight) => (
<div
key={highlight}
className='rounded-2xl border border-blue-100 bg-blue-50/70 p-4 text-sm leading-6 text-blue-900 dark:border-dark-700 dark:bg-dark-800 dark:text-slate-200'
>
{highlight}
</div>
))}
</div>
<div>
<h4 className='text-xl font-semibold text-gray-900 dark:text-white'>Why we&apos;re better</h4>
<div className='mt-4 grid gap-4 md:grid-cols-3'>
{competitorAdvantages.map((item) => (
<div key={item.title} className='rounded-2xl border border-gray-200 p-4 dark:border-dark-700'>
<h5 className='font-semibold text-gray-900 dark:text-white'>{item.title}</h5>
<p className='mt-2 text-sm leading-6 text-gray-600 dark:text-slate-300'>
{item.description}
</p>
</div>
))}
</div>
</div>
<div>
<div className='flex flex-col justify-between gap-2 md:flex-row md:items-end'>
<div>
<p className='text-sm font-semibold uppercase tracking-[0.2em] text-blue-600'>Pricing</p>
<h4 className='mt-2 text-2xl font-semibold text-gray-900 dark:text-white'>Simple monthly plans</h4>
</div>
<p className='text-sm text-gray-500 dark:text-slate-400'>Upgrade when your review workflow grows.</p>
</div>
<div className='mt-4 grid gap-4 md:grid-cols-2'>
{pricingPlans.map((plan) => (
<div
key={plan.name}
className='rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-dark-700 dark:bg-dark-900'
>
<div className='flex items-start justify-between gap-3'>
<div>
<h5 className='text-lg font-semibold text-gray-900 dark:text-white'>{plan.name}</h5>
<p className='mt-1 text-sm leading-6 text-gray-600 dark:text-slate-300'>{plan.description}</p>
</div>
<div className='text-right'>
<span className='text-3xl font-bold text-blue-600'>{plan.price}</span>
<span className='block text-xs text-gray-500 dark:text-slate-400'>/month</span>
</div>
</div>
<div className='mt-5 space-y-5'>
{plan.sections.map((section) => (
<div key={section.title}>
<h6 className='text-sm font-semibold uppercase tracking-wide text-gray-900 dark:text-white'>
{section.title}
</h6>
<ul className='mt-2 space-y-2 text-sm text-gray-700 dark:text-slate-300'>
{section.features.map((feature) => (
<li key={feature} className='flex gap-2'>
<span className='font-semibold text-blue-600'></span>
<span>{feature}</span>
</li>
))}
</ul>
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
</CardBox>
</div>
</div>
</SectionFullScreen>

View File

@ -0,0 +1,344 @@
import {
mdiArrowLeft,
mdiCheckCircleOutline,
mdiStar,
} from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import { useRouter } from 'next/router';
import React, { FormEvent, ReactElement, useEffect, useState } from 'react';
import BaseButton from '../../components/BaseButton';
import CardBox from '../../components/CardBox';
import LayoutGuest from '../../layouts/Guest';
import { getPageTitle } from '../../config';
interface HostedReviewProduct {
name?: string;
sku?: string;
quantity?: number | null;
}
interface HostedReviewPayload {
provider?: string;
order?: {
id?: string;
name?: string;
orderNumber?: string;
};
products?: HostedReviewProduct[];
}
interface HostedReviewRequest {
id: string;
status?: string;
review_platform?: string;
review_rating?: number | null;
review_title?: string | null;
review_content?: string | null;
reviewer_display_name?: string | null;
review_payload?: HostedReviewPayload | null;
business?: {
name?: string;
} | null;
customer?: {
name?: string;
email?: string;
} | null;
transaction?: {
payment_provider?: string;
description?: string;
amount?: string | number;
currency?: string;
} | null;
}
const ratingOptions = [1, 2, 3, 4, 5];
function getErrorMessage(error: unknown) {
if (axios.isAxiosError(error) && error.response?.data) {
const responseData = error.response.data;
if (typeof responseData === 'string') {
return responseData;
}
if (typeof responseData === 'object' && 'message' in responseData) {
return String(responseData.message);
}
}
return 'Something went wrong. Please try again.';
}
function formatAmount(amount?: string | number, currency?: string) {
const numericAmount = Number(amount);
if (!Number.isFinite(numericAmount)) {
return '';
}
return new Intl.NumberFormat('en', {
style: 'currency',
currency: currency || 'USD',
}).format(numericAmount);
}
export default function HostedReviewPage() {
const router = useRouter();
const trackingToken = Array.isArray(router.query.trackingToken)
? router.query.trackingToken[0]
: router.query.trackingToken;
const [review, setReview] = useState<HostedReviewRequest | null>(null);
const [rating, setRating] = useState(5);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [reviewerName, setReviewerName] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (!trackingToken) return;
const loadReview = async () => {
setIsLoading(true);
setError('');
try {
const response = await axios.get(
`/reviewflow-public/reviews/${trackingToken}`,
);
const loadedReview = response.data.review as HostedReviewRequest;
setReview(loadedReview);
setRating(loadedReview.review_rating || 5);
setTitle(loadedReview.review_title || '');
setContent(loadedReview.review_content || '');
setReviewerName(
loadedReview.reviewer_display_name || loadedReview.customer?.name || '',
);
setIsSubmitted(loadedReview.status === 'reviewed');
} catch (requestError) {
console.error('Failed to load hosted review request:', requestError);
setError(getErrorMessage(requestError));
} finally {
setIsLoading(false);
}
};
loadReview();
}, [trackingToken]);
const submitReview = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!trackingToken) return;
setIsSubmitting(true);
setError('');
try {
const response = await axios.post(
`/reviewflow-public/reviews/${trackingToken}`,
{
rating,
title,
content,
reviewerName,
},
);
setReview(response.data.review);
setIsSubmitted(true);
} catch (requestError) {
console.error('Failed to submit hosted review:', requestError);
setError(getErrorMessage(requestError));
} finally {
setIsSubmitting(false);
}
};
const businessName = review?.business?.name || 'this business';
const products = review?.review_payload?.products || [];
const orderName =
review?.review_payload?.order?.name || review?.transaction?.description || '';
const amount = formatAmount(
review?.transaction?.amount,
review?.transaction?.currency,
);
return (
<>
<Head>
<title>{getPageTitle(`Review ${businessName}`)}</title>
</Head>
<main className='min-h-screen bg-gradient-to-br from-slate-950 via-indigo-950 to-emerald-950 px-4 py-10 text-slate-100'>
<div className='mx-auto max-w-3xl'>
<div className='mb-6 text-center'>
<p className='text-sm font-bold uppercase tracking-[0.3em] text-emerald-300'>
Review Flow
</p>
<h1 className='mt-3 text-4xl font-black tracking-tight md:text-5xl'>
Share your experience with {businessName}
</h1>
<p className='mt-3 text-base text-slate-300'>
Your feedback helps the team improve and helps future customers know what to expect.
</p>
</div>
<CardBox className='border-0 bg-white text-slate-800 shadow-2xl dark:bg-slate-900 dark:text-slate-100'>
{isLoading ? (
<div className='rounded-2xl border border-dashed border-slate-200 p-8 text-center text-slate-500 dark:border-slate-700'>
Loading your review form...
</div>
) : error ? (
<div className='space-y-4 rounded-2xl border border-rose-200 bg-rose-50 p-6 text-rose-900'>
<p className='font-black'>We could not load this review request.</p>
<p>{error}</p>
<BaseButton
href='/'
icon={mdiArrowLeft}
label='Back to website'
color='whiteDark'
/>
</div>
) : isSubmitted ? (
<div className='rounded-3xl bg-emerald-50 p-8 text-center text-emerald-950'>
<BaseButton icon={mdiCheckCircleOutline} color='success' roundedFull />
<h2 className='mt-4 text-3xl font-black'>Thank you for your review!</h2>
<p className='mt-3 text-emerald-800'>
Your feedback was submitted successfully.
</p>
{review?.review_rating && (
<div className='mt-5 flex justify-center gap-1 text-amber-500'>
{ratingOptions.map((option) => (
<span key={option}>{option <= Number(review.review_rating || 0) ? '★' : '☆'}</span>
))}
</div>
)}
</div>
) : (
<form onSubmit={submitReview} className='space-y-6'>
<div className='rounded-2xl bg-slate-50 p-4 dark:bg-slate-800'>
<p className='text-xs font-bold uppercase tracking-widest text-slate-400'>
Review context
</p>
<h2 className='mt-1 text-2xl font-black text-slate-900 dark:text-white'>
{businessName}
</h2>
{(orderName || amount || review?.transaction?.payment_provider) && (
<p className='mt-1 text-sm text-slate-500 dark:text-slate-300'>
{review?.transaction?.payment_provider || 'Order'}
{orderName ? ` · ${orderName}` : ''}
{amount ? ` · ${amount}` : ''}
</p>
)}
{products.length > 0 && (
<div className='mt-4 grid gap-2'>
{products.map((product, index) => (
<div
key={`${product.name || 'product'}-${index}`}
className='rounded-xl bg-white p-3 text-sm ring-1 ring-slate-200 dark:bg-slate-900 dark:ring-slate-700'
>
<p className='font-bold text-slate-900 dark:text-white'>
{product.name || 'Purchased item'}
</p>
<p className='text-slate-500'>
{product.sku ? `SKU ${product.sku}` : 'Shopify product'}
{product.quantity ? ` · Qty ${product.quantity}` : ''}
</p>
</div>
))}
</div>
)}
</div>
<div>
<label className='mb-2 block text-sm font-black text-slate-900 dark:text-white'>
Your rating
</label>
<div className='flex flex-wrap gap-2'>
{ratingOptions.map((option) => (
<button
key={option}
type='button'
onClick={() => setRating(option)}
className={`inline-flex h-12 w-12 items-center justify-center rounded-2xl border text-lg font-black transition ${
option <= rating
? 'border-amber-300 bg-amber-100 text-amber-600'
: 'border-slate-200 bg-white text-slate-400 dark:border-slate-700 dark:bg-slate-900'
}`}
aria-label={`${option} star rating`}
>
<svg viewBox='0 0 24 24' className='h-5 w-5' aria-hidden='true'>
<path fill='currentColor' d={mdiStar} />
</svg>
</button>
))}
</div>
</div>
<div>
<label className='mb-2 block text-sm font-black text-slate-900 dark:text-white'>
Review title <span className='font-normal text-slate-400'>(optional)</span>
</label>
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
className='h-11 w-full rounded-xl border border-slate-300 px-3 py-2 text-slate-900 outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 dark:border-slate-700 dark:bg-slate-900 dark:text-white'
placeholder='What stood out?'
maxLength={200}
/>
</div>
<div>
<label className='mb-2 block text-sm font-black text-slate-900 dark:text-white'>
Your review
</label>
<textarea
required
value={content}
onChange={(event) => setContent(event.target.value)}
className='min-h-36 w-full rounded-xl border border-slate-300 px-3 py-2 text-slate-900 outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 dark:border-slate-700 dark:bg-slate-900 dark:text-white'
placeholder='Tell us about your experience...'
maxLength={5000}
/>
</div>
<div>
<label className='mb-2 block text-sm font-black text-slate-900 dark:text-white'>
Display name <span className='font-normal text-slate-400'>(optional)</span>
</label>
<input
value={reviewerName}
onChange={(event) => setReviewerName(event.target.value)}
className='h-11 w-full rounded-xl border border-slate-300 px-3 py-2 text-slate-900 outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 dark:border-slate-700 dark:bg-slate-900 dark:text-white'
placeholder='Your name'
maxLength={120}
/>
</div>
{error && (
<div className='rounded-2xl border border-rose-200 bg-rose-50 p-4 text-rose-900'>
{error}
</div>
)}
<BaseButton
type='submit'
icon={mdiCheckCircleOutline}
label={isSubmitting ? 'Submitting...' : 'Submit review'}
color='success'
disabled={isSubmitting}
/>
</form>
)}
</CardBox>
</div>
</main>
</>
);
}
HostedReviewPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -70,6 +70,8 @@ interface ReviewRequest {
email_subject?: string;
email_body?: string;
review_link?: string;
review_platform?: string;
review_rating?: number;
createdAt?: string;
business?: ReviewBusiness;
customer?: ReviewCustomer;
@ -93,6 +95,7 @@ interface SummaryResponse {
const defaultForm = {
businessName: 'Review Flow Studio',
reviewDestination: 'google',
reviewLink: 'https://g.page/r/example/review',
delayDays: '7',
customerName: '',
@ -100,6 +103,17 @@ const defaultForm = {
phone: '',
};
const reviewDestinationOptions = [
{ key: 'google', label: 'Google', requiresLink: true },
{ key: 'facebook', label: 'Facebook', requiresLink: true },
{ key: 'yelp', label: 'Yelp', requiresLink: true },
{ key: 'angi', label: 'Angi', requiresLink: true },
{ key: 'opentable', label: 'OpenTable', requiresLink: true },
{ key: 'trustpilot', label: 'Trustpilot', requiresLink: true },
{ key: 'shopify_hosted', label: 'Shopify hosted product review', requiresLink: false },
{ key: 'custom', label: 'Custom review page', requiresLink: true },
];
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',
@ -165,6 +179,11 @@ export default function ReviewFlowWorkspace() {
transactions: 0,
paymentEvents: 0,
};
const selectedReviewDestination =
reviewDestinationOptions.find(
(destination) => destination.key === form.reviewDestination,
) || reviewDestinationOptions[0];
const isHostedReviewDestination = !selectedReviewDestination.requiresLink;
const previewDate = useMemo(() => {
if (!isClientReady) return 'after the selected delay';
@ -220,6 +239,7 @@ export default function ReviewFlowWorkspace() {
try {
const response = await axios.post('/reviewflow/request', {
...form,
reviewLink: isHostedReviewDestination ? '' : form.reviewLink,
delayDays: Number(form.delayDays),
});
const newRequest = response.data.request;
@ -253,6 +273,7 @@ export default function ReviewFlowWorkspace() {
setForm((current) => ({
...current,
businessName: connectorForm.businessName,
reviewDestination: connectorForm.reviewDestination,
reviewLink: connectorForm.reviewLink,
delayDays: connectorForm.delayDays,
}));
@ -282,15 +303,13 @@ export default function ReviewFlowWorkspace() {
<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
Clean workflow · trigger customer right review destination
</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.
Keep ecommerce triggers and local review destinations cleanly separated.
</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.
Stripe, Square, PayPal, Shopify, and WooCommerce create customers and transactions from webhooks. Google, Facebook, Yelp, Angi, OpenTable, Trustpilot, and Shopify hosted reviews are treated as review destinations.
</p>
</div>
<div className='grid grid-cols-2 gap-3'>
@ -357,8 +376,8 @@ export default function ReviewFlowWorkspace() {
<form onSubmit={handleSubmit}>
<FormField
label='Business setup'
help='Use the review link where customers should land.'
label='Business and review destination'
help='Choose the destination first. Shopify hosted reviews generate a Review Flow form automatically.'
>
<input
required
@ -368,15 +387,42 @@ export default function ReviewFlowWorkspace() {
}
placeholder='Business name'
/>
<input
required
type='url'
value={form.reviewLink}
<select
value={form.reviewDestination}
onChange={(event) =>
updateForm('reviewLink', event.target.value)
updateForm('reviewDestination', event.target.value)
}
placeholder='https://g.page/.../review'
/>
>
{reviewDestinationOptions.map((destination) => (
<option key={destination.key} value={destination.key}>
{destination.label}
</option>
))}
</select>
</FormField>
<FormField
label={isHostedReviewDestination ? 'Hosted review form' : 'External review link'}
help={
isHostedReviewDestination
? 'No external URL needed; the outgoing email points to a hosted /review page.'
: 'Use the exact review page where this customer should land.'
}
>
{isHostedReviewDestination ? (
<div className='rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-sm font-semibold text-emerald-900'>
Review Flow will create a secure hosted product-review link for this request.
</div>
) : (
<input
required
type='url'
value={form.reviewLink}
onChange={(event) =>
updateForm('reviewLink', event.target.value)
}
placeholder='https://your-review-destination.example/review'
/>
)}
</FormField>
<FormField
label='Customer'
@ -560,7 +606,7 @@ export default function ReviewFlowWorkspace() {
</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
Review link / hosted form
</p>
<Link
href={selected.review_link || '#'}