Autosave: 20260629-060213
This commit is contained in:
parent
e4186ae090
commit
df9c6cb725
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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: {
|
||||
|
||||
@ -48,6 +48,21 @@ paypal_customer_reference: {
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
|
||||
shopify_customer_reference: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
woocommerce_customer_reference: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
contact_status: {
|
||||
|
||||
@ -1,9 +1,3 @@
|
||||
const config = require('../../config');
|
||||
const providers = config.providers;
|
||||
const crypto = require('crypto');
|
||||
const bcrypt = require('bcrypt');
|
||||
const moment = require('moment');
|
||||
|
||||
module.exports = function(sequelize, DataTypes) {
|
||||
const 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: {
|
||||
|
||||
@ -34,6 +34,21 @@ paypal_payment_reference: {
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
|
||||
shopify_order_reference: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
woocommerce_order_reference: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
provider_event_reference: {
|
||||
|
||||
@ -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);
|
||||
|
||||
24
backend/src/routes/reviewflow-public.js
Normal file
24
backend/src/routes/reviewflow-public.js
Normal 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;
|
||||
@ -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 });
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 Flow’s 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,
|
||||
};
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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'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>
|
||||
|
||||
344
frontend/src/pages/review/[trackingToken].tsx
Normal file
344
frontend/src/pages/review/[trackingToken].tsx
Normal 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>;
|
||||
};
|
||||
@ -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 || '#'}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user