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: {
|
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: {
|
importHash: {
|
||||||
|
|||||||
@ -48,6 +48,21 @@ paypal_customer_reference: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
shopify_customer_reference: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
woocommerce_customer_reference: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
contact_status: {
|
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) {
|
module.exports = function(sequelize, DataTypes) {
|
||||||
const review_requests = sequelize.define(
|
const review_requests = sequelize.define(
|
||||||
'review_requests',
|
'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: {
|
importHash: {
|
||||||
|
|||||||
@ -34,6 +34,21 @@ paypal_payment_reference: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
shopify_order_reference: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
woocommerce_order_reference: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
provider_event_reference: {
|
provider_event_reference: {
|
||||||
|
|||||||
@ -36,6 +36,7 @@ const review_requestsRoutes = require('./routes/review_requests');
|
|||||||
|
|
||||||
const reviewflowRoutes = require('./routes/reviewflow');
|
const reviewflowRoutes = require('./routes/reviewflow');
|
||||||
const reviewflowWebhooksRoutes = require('./routes/reviewflow-webhooks');
|
const reviewflowWebhooksRoutes = require('./routes/reviewflow-webhooks');
|
||||||
|
const reviewflowPublicRoutes = require('./routes/reviewflow-public');
|
||||||
|
|
||||||
const stripe_eventsRoutes = require('./routes/stripe_events');
|
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-webhooks', reviewflowWebhooksRoutes);
|
||||||
|
|
||||||
|
app.use('/api/reviewflow-public', reviewflowPublicRoutes);
|
||||||
|
|
||||||
app.use('/api/stripe_events', passport.authenticate('jwt', {session: false}), stripe_eventsRoutes);
|
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);
|
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.businessId,
|
||||||
req.params.secretToken,
|
req.params.secretToken,
|
||||||
req.body,
|
req.body,
|
||||||
|
req.headers,
|
||||||
);
|
);
|
||||||
|
|
||||||
res.status(200).send({ received: true, ...result });
|
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) {
|
function buildEmailBody(customerName, businessName, reviewLink) {
|
||||||
const greetingName = customerName || 'there';
|
const greetingName = customerName || 'there';
|
||||||
return [
|
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) => {
|
router.get('/connectors', wrapAsync(async (req, res) => {
|
||||||
const businesses = await ReviewFlowService.listConnectorBusinesses(req.currentUser, req);
|
const businesses = await ReviewFlowService.listConnectorBusinesses(req.currentUser, req);
|
||||||
|
|
||||||
@ -136,13 +165,18 @@ router.post('/request', wrapAsync(async (req, res) => {
|
|||||||
const body = req.body || {};
|
const body = req.body || {};
|
||||||
const businessName = normalizeString(body.businessName);
|
const businessName = normalizeString(body.businessName);
|
||||||
const reviewLink = normalizeString(body.reviewLink);
|
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 customerEmail = normalizeString(body.customerEmail).toLowerCase();
|
||||||
const customerName = normalizeString(body.customerName);
|
const customerName = normalizeString(body.customerName);
|
||||||
const phone = normalizeString(body.phone);
|
const phone = normalizeString(body.phone);
|
||||||
const delayDays = Math.max(0, Math.min(Number(body.delayDays) || 0, 30));
|
const delayDays = Math.max(0, Math.min(Number(body.delayDays) || 0, 30));
|
||||||
|
|
||||||
requireField(businessName, 'Business name is required.');
|
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.');
|
requireField(customerEmail, 'Customer email is required.');
|
||||||
|
|
||||||
if (!EMAIL_PATTERN.test(customerEmail)) {
|
if (!EMAIL_PATTERN.test(customerEmail)) {
|
||||||
@ -151,33 +185,53 @@ router.post('/request', wrapAsync(async (req, res) => {
|
|||||||
throw error;
|
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 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();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
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({
|
const [business] = await db.businesses.findOrCreate({
|
||||||
where: { name: businessName, createdById: currentUser.id },
|
where: { name: businessName, createdById: currentUser.id },
|
||||||
defaults: {
|
defaults: businessDefaults,
|
||||||
name: businessName,
|
|
||||||
google_review_link: reviewLink,
|
|
||||||
delay_days: delayDays,
|
|
||||||
email_subject_template: `How was your experience with ${businessName}?`,
|
|
||||||
email_body_template: buildEmailBody('{customerName}', businessName, '{reviewLink}'),
|
|
||||||
is_active: true,
|
|
||||||
createdById: currentUser.id,
|
|
||||||
updatedById: currentUser.id,
|
|
||||||
},
|
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
await business.update({
|
const businessUpdates = {
|
||||||
google_review_link: reviewLink,
|
review_destination: reviewDestination,
|
||||||
|
shopify_hosted_reviews_enabled: business.shopify_hosted_reviews_enabled || isHostedReviewDestination,
|
||||||
delay_days: delayDays,
|
delay_days: delayDays,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
updatedById: currentUser.id,
|
updatedById: currentUser.id,
|
||||||
}, { transaction });
|
};
|
||||||
|
|
||||||
|
if (reviewLink && reviewLinkField) {
|
||||||
|
businessUpdates[reviewLinkField] = reviewLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
await business.update(businessUpdates, { transaction });
|
||||||
|
|
||||||
const [customer] = await db.customers.findOrCreate({
|
const [customer] = await db.customers.findOrCreate({
|
||||||
where: { email: customerEmail, createdById: currentUser.id },
|
where: { email: customerEmail, createdById: currentUser.id },
|
||||||
@ -206,9 +260,10 @@ router.post('/request', wrapAsync(async (req, res) => {
|
|||||||
status: 'pending',
|
status: 'pending',
|
||||||
scheduled_for: scheduledFor,
|
scheduled_for: scheduledFor,
|
||||||
email_subject: emailSubject,
|
email_subject: emailSubject,
|
||||||
email_body: buildEmailBody(customerName, businessName, reviewLink),
|
email_body: buildEmailBody(customerName, businessName, effectiveReviewLink),
|
||||||
review_link: reviewLink,
|
review_link: effectiveReviewLink,
|
||||||
tracking_token: crypto.randomBytes(18).toString('hex'),
|
tracking_token: trackingToken,
|
||||||
|
review_platform: reviewDestination,
|
||||||
businessId: business.id,
|
businessId: business.id,
|
||||||
customerId: customer.id,
|
customerId: customer.id,
|
||||||
createdById: currentUser.id,
|
createdById: currentUser.id,
|
||||||
|
|||||||
@ -24,6 +24,8 @@ const ZERO_DECIMAL_CURRENCIES = new Set([
|
|||||||
const PROVIDERS = {
|
const PROVIDERS = {
|
||||||
stripe: {
|
stripe: {
|
||||||
label: 'Stripe',
|
label: 'Stripe',
|
||||||
|
category: 'payment_trigger',
|
||||||
|
defaultReviewDestination: 'google',
|
||||||
accountField: 'stripe_account_reference',
|
accountField: 'stripe_account_reference',
|
||||||
connectedField: 'stripe_connected',
|
connectedField: 'stripe_connected',
|
||||||
connectedAtField: 'stripe_connected_at',
|
connectedAtField: 'stripe_connected_at',
|
||||||
@ -33,6 +35,8 @@ const PROVIDERS = {
|
|||||||
},
|
},
|
||||||
square: {
|
square: {
|
||||||
label: 'Square',
|
label: 'Square',
|
||||||
|
category: 'payment_trigger',
|
||||||
|
defaultReviewDestination: 'google',
|
||||||
accountField: 'square_account_reference',
|
accountField: 'square_account_reference',
|
||||||
connectedField: 'square_connected',
|
connectedField: 'square_connected',
|
||||||
connectedAtField: 'square_connected_at',
|
connectedAtField: 'square_connected_at',
|
||||||
@ -42,6 +46,8 @@ const PROVIDERS = {
|
|||||||
},
|
},
|
||||||
paypal: {
|
paypal: {
|
||||||
label: 'PayPal',
|
label: 'PayPal',
|
||||||
|
category: 'payment_trigger',
|
||||||
|
defaultReviewDestination: 'google',
|
||||||
accountField: 'paypal_merchant_reference',
|
accountField: 'paypal_merchant_reference',
|
||||||
connectedField: 'paypal_connected',
|
connectedField: 'paypal_connected',
|
||||||
connectedAtField: 'paypal_connected_at',
|
connectedAtField: 'paypal_connected_at',
|
||||||
@ -49,6 +55,104 @@ const PROVIDERS = {
|
|||||||
paymentReferenceField: 'paypal_payment_reference',
|
paymentReferenceField: 'paypal_payment_reference',
|
||||||
customerReferenceField: 'paypal_customer_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) {
|
function normalizeString(value) {
|
||||||
@ -59,6 +163,50 @@ function normalizeEmail(value) {
|
|||||||
return normalizeString(value).toLowerCase();
|
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) {
|
function httpError(message, code) {
|
||||||
const error = new Error(message);
|
const error = new Error(message);
|
||||||
error.code = code;
|
error.code = code;
|
||||||
@ -87,7 +235,7 @@ function getProviderConfig(provider) {
|
|||||||
const config = PROVIDERS[normalizedProvider];
|
const config = PROVIDERS[normalizedProvider];
|
||||||
|
|
||||||
if (!config) {
|
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 };
|
return { provider: normalizedProvider, ...config };
|
||||||
@ -102,13 +250,43 @@ function getOwnerId(business) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getRequestOrigin(req) {
|
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 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}`;
|
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) {
|
function getWebhookUrl(req, business, provider) {
|
||||||
const config = getProviderConfig(provider);
|
const config = getProviderConfig(provider);
|
||||||
const token = business[config.tokenField];
|
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}`;
|
return `${getRequestOrigin(req)}/api/reviewflow-webhooks/${config.provider}/${business.id}/${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getReviewLink(business) {
|
function getReviewLink(business, options = {}) {
|
||||||
const platform = business.default_review_platform || 'google';
|
const destination = getNormalizedReviewDestination(
|
||||||
|
options.reviewDestination || getReviewDestination(business),
|
||||||
|
);
|
||||||
|
const channel = getReviewChannel(destination);
|
||||||
|
|
||||||
if (platform === 'custom') {
|
if (channel.mode === 'hosted_form') {
|
||||||
return business.custom_review_link || business.google_review_link || business.yelp_review_link || business.facebook_review_link || '';
|
if (options.req) {
|
||||||
|
return getHostedReviewUrl(options.req, options.trackingToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getHostedReviewUrlFromHeaders(options.headers, options.trackingToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (platform === 'yelp') {
|
const directLink = channel.linkField ? business[channel.linkField] : '';
|
||||||
return business.yelp_review_link || business.google_review_link || business.facebook_review_link || business.custom_review_link || '';
|
|
||||||
|
if (directLink) {
|
||||||
|
return directLink;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (platform === 'facebook') {
|
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 || '';
|
||||||
return business.facebook_review_link || business.google_review_link || business.yelp_review_link || business.custom_review_link || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return business.google_review_link || business.custom_review_link || business.yelp_review_link || business.facebook_review_link || '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildEmailBody(customerName, businessName, reviewLink) {
|
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) {
|
function normalizeEventType(provider, providerEventType, payment) {
|
||||||
const eventType = normalizeString(providerEventType);
|
const eventType = normalizeString(providerEventType);
|
||||||
const status = normalizeString(payment?.status).toLowerCase();
|
const status = normalizeString(payment?.status).toLowerCase();
|
||||||
@ -328,10 +628,26 @@ function normalizeEventType(provider, providerEventType, payment) {
|
|||||||
if (eventType.includes('COMPLETED')) return 'payment_intent_succeeded';
|
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';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePaymentEvent(provider, payload) {
|
function normalizePaymentEvent(provider, payload, headers = {}) {
|
||||||
if (provider === 'stripe') {
|
if (provider === 'stripe') {
|
||||||
return normalizeStripeEvent(payload);
|
return normalizeStripeEvent(payload);
|
||||||
}
|
}
|
||||||
@ -340,7 +656,15 @@ function normalizePaymentEvent(provider, payload) {
|
|||||||
return normalizeSquareEvent(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) {
|
function serializeBusiness(req, business) {
|
||||||
@ -350,8 +674,13 @@ function serializeBusiness(req, business) {
|
|||||||
google_review_link: business.google_review_link,
|
google_review_link: business.google_review_link,
|
||||||
yelp_review_link: business.yelp_review_link,
|
yelp_review_link: business.yelp_review_link,
|
||||||
facebook_review_link: business.facebook_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,
|
custom_review_link: business.custom_review_link,
|
||||||
default_review_platform: business.default_review_platform,
|
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,
|
delay_days: business.delay_days,
|
||||||
providers: Object.keys(PROVIDERS).map((providerKey) => {
|
providers: Object.keys(PROVIDERS).map((providerKey) => {
|
||||||
const config = getProviderConfig(providerKey);
|
const config = getProviderConfig(providerKey);
|
||||||
@ -360,6 +689,9 @@ function serializeBusiness(req, business) {
|
|||||||
return {
|
return {
|
||||||
key: providerKey,
|
key: providerKey,
|
||||||
label: config.label,
|
label: config.label,
|
||||||
|
category: config.category,
|
||||||
|
default_review_destination: config.defaultReviewDestination,
|
||||||
|
hosted_review_provider: Boolean(config.hostedReviewProvider),
|
||||||
connected: Boolean(business[config.connectedField]),
|
connected: Boolean(business[config.connectedField]),
|
||||||
connected_at: business[config.connectedAtField] || null,
|
connected_at: business[config.connectedAtField] || null,
|
||||||
account_reference: business[config.accountField] || '',
|
account_reference: business[config.accountField] || '',
|
||||||
@ -387,6 +719,10 @@ async function connectProvider(currentUser, body, req) {
|
|||||||
const businessName = normalizeString(body.businessName);
|
const businessName = normalizeString(body.businessName);
|
||||||
const reviewLink = normalizeString(body.reviewLink);
|
const reviewLink = normalizeString(body.reviewLink);
|
||||||
const accountReference = normalizeString(body.accountReference);
|
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));
|
const delayDays = Math.max(0, Math.min(Number(body.delayDays) || 0, 30));
|
||||||
let business;
|
let business;
|
||||||
|
|
||||||
@ -406,9 +742,10 @@ async function connectProvider(currentUser, body, req) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!business) {
|
if (!business) {
|
||||||
business = await db.businesses.create({
|
const createPayload = {
|
||||||
name: businessName,
|
name: businessName,
|
||||||
google_review_link: reviewLink || null,
|
review_destination: reviewDestination,
|
||||||
|
shopify_hosted_reviews_enabled: Boolean(config.hostedReviewProvider || reviewDestination === 'shopify_hosted'),
|
||||||
delay_days: delayDays,
|
delay_days: delayDays,
|
||||||
email_subject_template: `How was your experience with ${businessName}?`,
|
email_subject_template: `How was your experience with ${businessName}?`,
|
||||||
email_body_template: buildEmailBody('{customerName}', businessName, '{reviewLink}'),
|
email_body_template: buildEmailBody('{customerName}', businessName, '{reviewLink}'),
|
||||||
@ -416,12 +753,22 @@ async function connectProvider(currentUser, body, req) {
|
|||||||
createdById: currentUser.id,
|
createdById: currentUser.id,
|
||||||
updatedById: currentUser.id,
|
updatedById: currentUser.id,
|
||||||
ownerId: currentUser.id,
|
ownerId: currentUser.id,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (reviewLink && reviewLinkField) {
|
||||||
|
createPayload[reviewLinkField] = reviewLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
business = await db.businesses.create(createPayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updates = {
|
const updates = {
|
||||||
is_active: true,
|
is_active: true,
|
||||||
delay_days: delayDays,
|
delay_days: delayDays,
|
||||||
|
review_destination: reviewDestination,
|
||||||
|
shopify_hosted_reviews_enabled: Boolean(
|
||||||
|
business.shopify_hosted_reviews_enabled || config.hostedReviewProvider || reviewDestination === 'shopify_hosted',
|
||||||
|
),
|
||||||
updatedById: currentUser.id,
|
updatedById: currentUser.id,
|
||||||
[config.connectedField]: true,
|
[config.connectedField]: true,
|
||||||
[config.connectedAtField]: new Date(),
|
[config.connectedAtField]: new Date(),
|
||||||
@ -433,8 +780,8 @@ async function connectProvider(currentUser, body, req) {
|
|||||||
updates.name = businessName;
|
updates.name = businessName;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reviewLink) {
|
if (reviewLink && reviewLinkField) {
|
||||||
updates.google_review_link = reviewLink;
|
updates[reviewLinkField] = reviewLink;
|
||||||
}
|
}
|
||||||
|
|
||||||
await business.update(updates);
|
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,
|
stripe_payment_reference: payment.provider === 'stripe' ? payment.paymentReference || null : null,
|
||||||
square_payment_reference: payment.provider === 'square' ? payment.paymentReference || null : null,
|
square_payment_reference: payment.provider === 'square' ? payment.paymentReference || null : null,
|
||||||
paypal_payment_reference: payment.provider === 'paypal' ? 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,
|
provider_event_reference: payment.eventReference || null,
|
||||||
amount: payment.amount,
|
amount: payment.amount,
|
||||||
currency: payment.currency || null,
|
currency: payment.currency || null,
|
||||||
@ -537,9 +886,24 @@ async function createTransactionFromPayment(payment, business, customer, transac
|
|||||||
return { transactionRecord, duplicate: false };
|
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 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) {
|
if (!payment.isPaymentSuccess) {
|
||||||
return null;
|
return null;
|
||||||
@ -570,7 +934,9 @@ async function createReviewRequestFromPayment(payment, business, customer, trans
|
|||||||
email_subject: emailSubject,
|
email_subject: emailSubject,
|
||||||
email_body: emailBody,
|
email_body: emailBody,
|
||||||
review_link: reviewLink,
|
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,
|
businessId: business.id,
|
||||||
customerId: customer.id,
|
customerId: customer.id,
|
||||||
transactionId: transactionRecord.id,
|
transactionId: transactionRecord.id,
|
||||||
@ -579,7 +945,7 @@ async function createReviewRequestFromPayment(payment, business, customer, trans
|
|||||||
}, { transaction });
|
}, { transaction });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processPaymentWebhook(providerName, businessId, secretToken, payload) {
|
async function processPaymentWebhook(providerName, businessId, secretToken, payload, headers = {}) {
|
||||||
const { provider, ...config } = getProviderConfig(providerName);
|
const { provider, ...config } = getProviderConfig(providerName);
|
||||||
const business = await db.businesses.findByPk(businessId);
|
const business = await db.businesses.findByPk(businessId);
|
||||||
|
|
||||||
@ -593,7 +959,7 @@ async function processPaymentWebhook(providerName, businessId, secretToken, payl
|
|||||||
|
|
||||||
const payment = {
|
const payment = {
|
||||||
provider,
|
provider,
|
||||||
...normalizePaymentEvent(provider, payload || {}),
|
...normalizePaymentEvent(provider, payload || {}, headers),
|
||||||
};
|
};
|
||||||
const ownerId = getOwnerId(business);
|
const ownerId = getOwnerId(business);
|
||||||
const existingEvent = payment.eventReference ? await db.stripe_events.findOne({
|
const existingEvent = payment.eventReference ? await db.stripe_events.findOne({
|
||||||
@ -642,6 +1008,7 @@ async function processPaymentWebhook(providerName, businessId, secretToken, payl
|
|||||||
customer,
|
customer,
|
||||||
transactionRecord,
|
transactionRecord,
|
||||||
transaction,
|
transaction,
|
||||||
|
headers,
|
||||||
);
|
);
|
||||||
let processingError = null;
|
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 = {
|
module.exports = {
|
||||||
PROVIDERS,
|
PROVIDERS,
|
||||||
|
REVIEW_CHANNELS,
|
||||||
buildEmailBody,
|
buildEmailBody,
|
||||||
connectProvider,
|
connectProvider,
|
||||||
generateWebhookToken,
|
generateWebhookToken,
|
||||||
|
getHostedReviewRequest,
|
||||||
|
getHostedReviewUrl,
|
||||||
getProviderConfig,
|
getProviderConfig,
|
||||||
|
getReviewDestination,
|
||||||
|
getReviewLink,
|
||||||
getWebhookUrl,
|
getWebhookUrl,
|
||||||
listConnectorBusinesses,
|
listConnectorBusinesses,
|
||||||
processPaymentWebhook,
|
processPaymentWebhook,
|
||||||
rotateWebhookToken,
|
rotateWebhookToken,
|
||||||
serializeBusiness,
|
serializeBusiness,
|
||||||
|
serializeReviewChannels,
|
||||||
|
submitHostedReview,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -13,8 +13,11 @@ import CardBox from '../CardBox';
|
|||||||
import FormField from '../FormField';
|
import FormField from '../FormField';
|
||||||
|
|
||||||
export interface ProviderConnector {
|
export interface ProviderConnector {
|
||||||
key: 'stripe' | 'square' | 'paypal' | string;
|
key: 'stripe' | 'square' | 'paypal' | 'shopify' | 'woocommerce' | string;
|
||||||
label: string;
|
label: string;
|
||||||
|
category?: string;
|
||||||
|
default_review_destination?: string;
|
||||||
|
hosted_review_provider?: boolean;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
connected_at?: string | null;
|
connected_at?: string | null;
|
||||||
account_reference?: string;
|
account_reference?: string;
|
||||||
@ -27,6 +30,14 @@ export interface ConnectorBusiness {
|
|||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
google_review_link?: 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;
|
delay_days?: number;
|
||||||
providers: ProviderConnector[];
|
providers: ProviderConnector[];
|
||||||
}
|
}
|
||||||
@ -34,6 +45,7 @@ export interface ConnectorBusiness {
|
|||||||
export interface ConnectorFormValues {
|
export interface ConnectorFormValues {
|
||||||
provider: string;
|
provider: string;
|
||||||
businessName: string;
|
businessName: string;
|
||||||
|
reviewDestination: string;
|
||||||
reviewLink: string;
|
reviewLink: string;
|
||||||
delayDays: string;
|
delayDays: string;
|
||||||
accountReference: string;
|
accountReference: string;
|
||||||
@ -53,6 +65,7 @@ interface PaymentProviderConnectorsProps {
|
|||||||
const connectorDefaults: ConnectorFormValues = {
|
const connectorDefaults: ConnectorFormValues = {
|
||||||
provider: 'stripe',
|
provider: 'stripe',
|
||||||
businessName: 'Review Flow Studio',
|
businessName: 'Review Flow Studio',
|
||||||
|
reviewDestination: 'google',
|
||||||
reviewLink: 'https://g.page/r/example/review',
|
reviewLink: 'https://g.page/r/example/review',
|
||||||
delayDays: '7',
|
delayDays: '7',
|
||||||
accountReference: '',
|
accountReference: '',
|
||||||
@ -62,18 +75,110 @@ const providerOptions = [
|
|||||||
{
|
{
|
||||||
key: 'stripe',
|
key: 'stripe',
|
||||||
label: 'Stripe',
|
label: 'Stripe',
|
||||||
|
categoryLabel: 'Payment trigger',
|
||||||
|
defaultReviewDestination: 'google',
|
||||||
description: 'Connect card and checkout payments from Stripe.',
|
description: 'Connect card and checkout payments from Stripe.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'paypal',
|
key: 'paypal',
|
||||||
label: 'PayPal',
|
label: 'PayPal',
|
||||||
|
categoryLabel: 'Payment trigger',
|
||||||
|
defaultReviewDestination: 'google',
|
||||||
description: 'Connect completed PayPal captures and sales.',
|
description: 'Connect completed PayPal captures and sales.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'square',
|
key: 'square',
|
||||||
label: 'Square',
|
label: 'Square',
|
||||||
|
categoryLabel: 'Payment trigger',
|
||||||
|
defaultReviewDestination: 'google',
|
||||||
description: 'Connect Square payment notifications.',
|
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[]> = {
|
const providerInstructions: Record<string, string[]> = {
|
||||||
@ -92,6 +197,16 @@ const providerInstructions: Record<string, string[]> = {
|
|||||||
'Paste this Review Flow webhook URL as the webhook URL.',
|
'Paste this Review Flow webhook URL as the webhook URL.',
|
||||||
'Send PAYMENT.CAPTURE.COMPLETED or PAYMENT.SALE.COMPLETED events.',
|
'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<
|
const providerSetupDetails: Record<
|
||||||
@ -156,14 +271,54 @@ const providerSetupDetails: Record<
|
|||||||
testTip:
|
testTip:
|
||||||
'A completed PayPal capture, sale, or checkout order should appear as a payment event before a review request is queued.',
|
'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> = {
|
const providerGradient: Record<string, string> = {
|
||||||
stripe: 'from-indigo-600 to-violet-600',
|
stripe: 'from-indigo-600 to-violet-600',
|
||||||
square: 'from-emerald-600 to-teal-600',
|
square: 'from-emerald-600 to-teal-600',
|
||||||
paypal: 'from-sky-600 to-blue-700',
|
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) {
|
function formatDate(value?: string | null) {
|
||||||
if (!value) return 'Not scheduled';
|
if (!value) return 'Not scheduled';
|
||||||
|
|
||||||
@ -188,9 +343,9 @@ function isUnauthorizedError(error: unknown) {
|
|||||||
|
|
||||||
export default function PaymentProviderConnectors({
|
export default function PaymentProviderConnectors({
|
||||||
className = '',
|
className = '',
|
||||||
eyebrow = 'Payment webhooks',
|
eyebrow = 'Order triggers and review destinations',
|
||||||
title = 'Connect Stripe, PayPal, and Square',
|
title = 'Connect payment/ecommerce triggers without mixing local review channels',
|
||||||
description = 'Connect each payment provider once. Successful payment webhooks can create customers, save transactions, and queue review requests automatically.',
|
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,
|
onConnected,
|
||||||
}: PaymentProviderConnectorsProps) {
|
}: PaymentProviderConnectorsProps) {
|
||||||
const [connectorForm, setConnectorForm] =
|
const [connectorForm, setConnectorForm] =
|
||||||
@ -207,6 +362,16 @@ export default function PaymentProviderConnectors({
|
|||||||
providerOptions.find(
|
providerOptions.find(
|
||||||
(provider) => provider.key === connectorForm.provider,
|
(provider) => provider.key === connectorForm.provider,
|
||||||
) || providerOptions[0];
|
) || 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(() => {
|
const connectorPreviewDate = useMemo(() => {
|
||||||
if (!isClientReady) return 'after the selected delay';
|
if (!isClientReady) return 'after the selected delay';
|
||||||
@ -238,6 +403,21 @@ export default function PaymentProviderConnectors({
|
|||||||
setConnectorForm((current) => ({ ...current, [key]: value }));
|
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 () => {
|
const loadConnectors = async () => {
|
||||||
setIsConnectorLoading(true);
|
setIsConnectorLoading(true);
|
||||||
try {
|
try {
|
||||||
@ -277,20 +457,25 @@ export default function PaymentProviderConnectors({
|
|||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.post('/reviewflow/connectors', {
|
const submittedConnectorForm = {
|
||||||
...connectorForm,
|
...connectorForm,
|
||||||
|
reviewDestination: effectiveReviewDestination,
|
||||||
|
reviewLink: isHostedReviewDestination ? '' : connectorForm.reviewLink,
|
||||||
|
};
|
||||||
|
const response = await axios.post('/reviewflow/connectors', {
|
||||||
|
...submittedConnectorForm,
|
||||||
delayDays: Number(connectorForm.delayDays),
|
delayDays: Number(connectorForm.delayDays),
|
||||||
});
|
});
|
||||||
const business = response.data.business as ConnectorBusiness;
|
const business = response.data.business as ConnectorBusiness;
|
||||||
setConnectorMessage(
|
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();
|
await loadConnectors();
|
||||||
|
|
||||||
if (onConnected) {
|
if (onConnected) {
|
||||||
try {
|
try {
|
||||||
await onConnected(business, connectorForm);
|
await onConnected(business, submittedConnectorForm);
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
console.error(
|
console.error(
|
||||||
'Payment connector post-connect refresh failed:',
|
'Payment connector post-connect refresh failed:',
|
||||||
@ -380,9 +565,10 @@ export default function PaymentProviderConnectors({
|
|||||||
/>
|
/>
|
||||||
Secure connection note
|
Secure connection note
|
||||||
</div>
|
</div>
|
||||||
Payment providers must POST to a public webhook URL. These endpoints
|
Payment and ecommerce providers POST order/payment events to a public webhook URL.
|
||||||
are protected by the long secret token embedded in each generated URL.
|
Local review destinations do not use these webhooks; they are the places customers
|
||||||
Rotate a URL if it is ever exposed.
|
visit after a request. Shopify is the exception here: it triggers from orders and
|
||||||
|
Review Flow hosts the product-review form.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -397,7 +583,44 @@ export default function PaymentProviderConnectors({
|
|||||||
</div>
|
</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) => {
|
{providerOptions.map((provider) => {
|
||||||
const isSelected = connectorForm.provider === provider.key;
|
const isSelected = connectorForm.provider === provider.key;
|
||||||
|
|
||||||
@ -405,7 +628,7 @@ export default function PaymentProviderConnectors({
|
|||||||
<button
|
<button
|
||||||
key={provider.key}
|
key={provider.key}
|
||||||
type='button'
|
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 ${
|
className={`rounded-2xl border p-4 text-left transition hover:-translate-y-0.5 hover:shadow-lg ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'border-indigo-300 bg-indigo-50 ring-2 ring-indigo-100 dark:bg-indigo-950/30 dark:ring-indigo-900'
|
? '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
|
<div
|
||||||
className={`mb-3 h-2 rounded-full bg-gradient-to-r ${providerGradient[provider.key] || providerGradient.stripe}`}
|
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}
|
Connect {provider.label}
|
||||||
</p>
|
</p>
|
||||||
<p className='mt-1 text-sm text-slate-500 dark:text-slate-400'>
|
<p className='mt-1 text-sm text-slate-500 dark:text-slate-400'>
|
||||||
@ -436,13 +662,13 @@ export default function PaymentProviderConnectors({
|
|||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
value={connectorForm.provider}
|
value={connectorForm.provider}
|
||||||
onChange={(event) =>
|
onChange={(event) => updateSelectedProvider(event.target.value)}
|
||||||
updateConnectorForm('provider', event.target.value)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<option value='stripe'>Stripe</option>
|
{providerOptions.map((provider) => (
|
||||||
<option value='paypal'>PayPal</option>
|
<option key={`${provider.key}-option`} value={provider.key}>
|
||||||
<option value='square'>Square</option>
|
{provider.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
@ -461,18 +687,22 @@ export default function PaymentProviderConnectors({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField
|
<FormField
|
||||||
label='Review trigger settings'
|
label='Review destination'
|
||||||
help={`Successful payments will queue reviews for ${connectorPreviewDate}.`}
|
help={`${selectedProvider.label} events will queue ${selectedReviewDestination.label} requests for ${connectorPreviewDate}.`}
|
||||||
>
|
>
|
||||||
<input
|
<select
|
||||||
required
|
value={effectiveReviewDestination}
|
||||||
type='url'
|
disabled={selectedProvider.defaultReviewDestination === 'shopify_hosted'}
|
||||||
value={connectorForm.reviewLink}
|
|
||||||
onChange={(event) =>
|
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
|
<input
|
||||||
min='0'
|
min='0'
|
||||||
max='30'
|
max='30'
|
||||||
@ -484,6 +714,27 @@ export default function PaymentProviderConnectors({
|
|||||||
placeholder='Delay days'
|
placeholder='Delay days'
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</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'>
|
<div className='flex flex-wrap gap-3'>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
type='submit'
|
type='submit'
|
||||||
@ -513,7 +764,7 @@ export default function PaymentProviderConnectors({
|
|||||||
Installation guide
|
Installation guide
|
||||||
</p>
|
</p>
|
||||||
<h4 className='text-xl font-black text-slate-900 dark:text-white'>
|
<h4 className='text-xl font-black text-slate-900 dark:text-white'>
|
||||||
How to install payment webhooks
|
How to install provider webhooks
|
||||||
</h4>
|
</h4>
|
||||||
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400'>
|
<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
|
First connect a provider above to generate the secure Review Flow
|
||||||
@ -522,7 +773,7 @@ export default function PaymentProviderConnectors({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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) => {
|
{providerOptions.map((provider) => {
|
||||||
const setup = providerSetupDetails[provider.key];
|
const setup = providerSetupDetails[provider.key];
|
||||||
|
|
||||||
@ -592,25 +843,24 @@ export default function PaymentProviderConnectors({
|
|||||||
Connected accounts
|
Connected accounts
|
||||||
</p>
|
</p>
|
||||||
<h4 className='text-xl font-black text-slate-900 dark:text-white'>
|
<h4 className='text-xl font-black text-slate-900 dark:text-white'>
|
||||||
{providerSummary.connectedCount} of {providerSummary.totalCount}{' '}
|
{`${providerSummary.connectedCount} of ${providerSummary.totalCount} provider slots connected`}
|
||||||
provider slots connected
|
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isConnectorLoading ? (
|
{isConnectorLoading ? (
|
||||||
<div className='rounded-2xl border border-dashed border-slate-200 p-8 text-center text-slate-500'>
|
<div className='rounded-2xl border border-dashed border-slate-200 p-8 text-center text-slate-500'>
|
||||||
Loading payment connectors...
|
Loading webhook connectors...
|
||||||
</div>
|
</div>
|
||||||
) : connectors.length === 0 ? (
|
) : 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'>
|
<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 />
|
<BaseButton icon={mdiWebhook} color='info' roundedFull />
|
||||||
<p className='mt-3 font-bold text-slate-900 dark:text-white'>
|
<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>
|
||||||
<p className='mt-1 text-sm text-slate-500'>
|
<p className='mt-1 text-sm text-slate-500'>
|
||||||
Choose Stripe, PayPal, or Square above to generate your first secure
|
Choose Stripe, PayPal, Square, Shopify, or WooCommerce above to
|
||||||
webhook URL.
|
generate your first secure order/payment webhook URL.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -626,7 +876,7 @@ export default function PaymentProviderConnectors({
|
|||||||
{business.name}
|
{business.name}
|
||||||
</h4>
|
</h4>
|
||||||
<p className='text-sm text-slate-500'>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
@ -637,7 +887,7 @@ export default function PaymentProviderConnectors({
|
|||||||
small
|
small
|
||||||
/>
|
/>
|
||||||
</div>
|
</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) => (
|
{business.providers.map((provider) => (
|
||||||
<div
|
<div
|
||||||
key={`${business.id}-${provider.key}`}
|
key={`${business.id}-${provider.key}`}
|
||||||
@ -652,7 +902,7 @@ export default function PaymentProviderConnectors({
|
|||||||
{provider.label}
|
{provider.label}
|
||||||
</p>
|
</p>
|
||||||
<h5 className='text-lg font-black'>
|
<h5 className='text-lg font-black'>
|
||||||
Webhook receiver
|
{getProviderCardTitle(provider)}
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
@ -706,7 +956,7 @@ export default function PaymentProviderConnectors({
|
|||||||
</div>
|
</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'>
|
<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'>
|
<p className='mb-2 font-black text-slate-900 dark:text-white'>
|
||||||
Setup steps
|
Provider setup steps
|
||||||
</p>
|
</p>
|
||||||
<ol className='list-decimal space-y-1 pl-4'>
|
<ol className='list-decimal space-y-1 pl-4'>
|
||||||
{(providerInstructions[provider.key] || []).map(
|
{(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 className='grid gap-6 lg:grid-cols-[1.15fr_0.85fr] lg:items-center'>
|
||||||
<div>
|
<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'>
|
<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>
|
</p>
|
||||||
<h2 className='max-w-3xl text-4xl font-black tracking-tight md:text-5xl'>
|
<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>
|
</h2>
|
||||||
<p className='mt-4 max-w-2xl text-base text-slate-200 md:text-lg'>
|
<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.
|
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.
|
||||||
Once connected, successful payments can flow into Review Flow and
|
|
||||||
automatically create customers, transactions, and review
|
|
||||||
requests.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className='rounded-3xl bg-white/10 p-5 ring-1 ring-white/15 backdrop-blur'>
|
<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>
|
</div>
|
||||||
<h3 className='text-xl font-black'>How connection works</h3>
|
<h3 className='text-xl font-black'>How connection works</h3>
|
||||||
<p className='mt-2 text-sm leading-6 text-slate-200'>
|
<p className='mt-2 text-sm leading-6 text-slate-200'>
|
||||||
Pick a provider, enter the business review settings, then copy
|
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.
|
||||||
the generated webhook URL into that provider dashboard. You can
|
|
||||||
rotate URLs anytime if a secret is exposed.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -56,8 +51,8 @@ export default function ConnectPage() {
|
|||||||
|
|
||||||
<PaymentProviderConnectors
|
<PaymentProviderConnectors
|
||||||
eyebrow='Provider connections'
|
eyebrow='Provider connections'
|
||||||
title='Connect your payment accounts'
|
title='Connect triggers and choose review destinations'
|
||||||
description='Choose Stripe, PayPal, or Square below. Each provider gets its own secure webhook URL for the business you configure.'
|
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>
|
</SectionMain>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -9,12 +9,12 @@ import { getPageTitle } from '../config';
|
|||||||
|
|
||||||
const metrics = [
|
const metrics = [
|
||||||
['7 days', 'default review delay'],
|
['7 days', 'default review delay'],
|
||||||
['3 sources', 'Stripe, Square, PayPal'],
|
['5 sources', 'Stripe, Square, PayPal, Shopify, WooCommerce'],
|
||||||
['4 states', 'pending, sent, clicked, reviewed'],
|
['4 states', 'pending, sent, clicked, reviewed'],
|
||||||
];
|
];
|
||||||
|
|
||||||
const steps = [
|
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.'],
|
['Schedule', 'Create the customer, transaction, and review request automatically with your preferred delay.'],
|
||||||
['Track', 'Follow pending, sent, clicked, and reviewed requests from one workspace.'],
|
['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.
|
Ask at the perfect moment. Earn more five-star reviews.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-600">
|
<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>
|
</p>
|
||||||
<div className="mt-8 flex flex-wrap gap-3">
|
<div className="mt-8 flex flex-wrap gap-3">
|
||||||
<BaseButton href="/reviewflow" icon={mdiStarCircleOutline} label="Open Review Flow" color="info" className="shadow-xl shadow-indigo-600/20" />
|
<BaseButton href="/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 { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {toast, ToastContainer} from "react-toastify";
|
import {toast, ToastContainer} from "react-toastify";
|
||||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'
|
import { getPexelsImage } from '../helpers/pexels'
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -33,9 +33,7 @@ export default function Login() {
|
|||||||
photographer: undefined,
|
photographer: undefined,
|
||||||
photographer_url: undefined,
|
photographer_url: undefined,
|
||||||
})
|
})
|
||||||
const [ illustrationVideo, setIllustrationVideo ] = useState({video_files: []})
|
const [contentPosition] = useState<'left' | 'right' | 'background'>('left');
|
||||||
const [contentType, setContentType] = useState('video');
|
|
||||||
const [contentPosition, setContentPosition] = useState('left');
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector(
|
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector(
|
||||||
(state) => state.auth,
|
(state) => state.auth,
|
||||||
@ -46,13 +44,135 @@ export default function Login() {
|
|||||||
|
|
||||||
const title = 'Review Flow'
|
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( () => {
|
useEffect( () => {
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
const image = await getPexelsImage()
|
const image = await getPexelsImage()
|
||||||
const video = await getPexelsVideo()
|
|
||||||
setIllustrationImage(image);
|
setIllustrationImage(image);
|
||||||
setIllustrationVideo(video);
|
|
||||||
}
|
}
|
||||||
fetchData();
|
fetchData();
|
||||||
}, []);
|
}, []);
|
||||||
@ -115,32 +235,7 @@ export default function Login() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
const videoBlock = (video) => {
|
|
||||||
if (video?.video_files?.length > 0) {
|
|
||||||
return (
|
|
||||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
|
||||||
<video
|
|
||||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
>
|
|
||||||
<source src={video.video_files[0]?.link} type='video/mp4'/>
|
|
||||||
Your browser does not support the video tag.
|
|
||||||
</video>
|
|
||||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
|
||||||
<a
|
|
||||||
className='text-[8px]'
|
|
||||||
href={video.user.url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
>
|
|
||||||
Video by {video.user.name} on Pexels
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={contentPosition === 'background' ? {
|
<div style={contentPosition === 'background' ? {
|
||||||
@ -159,8 +254,7 @@ export default function Login() {
|
|||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<SectionFullScreen bg='violet'>
|
||||||
<div className={`flex ${contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'} min-h-screen w-full`}>
|
<div className={`flex ${contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'} min-h-screen w-full`}>
|
||||||
{contentType === 'image' && contentPosition !== 'background' ? imageBlock(illustrationImage) : null}
|
{contentPosition !== 'background' ? imageBlock(illustrationImage) : null}
|
||||||
{contentType === 'video' && contentPosition !== 'background' ? videoBlock(illustrationVideo) : null}
|
|
||||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
<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'>
|
<CardBox id="loginRoles" className='w-full md:w-3/5 lg:w-2/3'>
|
||||||
@ -257,6 +351,95 @@ export default function Login() {
|
|||||||
</Form>
|
</Form>
|
||||||
</Formik>
|
</Formik>
|
||||||
</CardBox>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</SectionFullScreen>
|
</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_subject?: string;
|
||||||
email_body?: string;
|
email_body?: string;
|
||||||
review_link?: string;
|
review_link?: string;
|
||||||
|
review_platform?: string;
|
||||||
|
review_rating?: number;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
business?: ReviewBusiness;
|
business?: ReviewBusiness;
|
||||||
customer?: ReviewCustomer;
|
customer?: ReviewCustomer;
|
||||||
@ -93,6 +95,7 @@ interface SummaryResponse {
|
|||||||
|
|
||||||
const defaultForm = {
|
const defaultForm = {
|
||||||
businessName: 'Review Flow Studio',
|
businessName: 'Review Flow Studio',
|
||||||
|
reviewDestination: 'google',
|
||||||
reviewLink: 'https://g.page/r/example/review',
|
reviewLink: 'https://g.page/r/example/review',
|
||||||
delayDays: '7',
|
delayDays: '7',
|
||||||
customerName: '',
|
customerName: '',
|
||||||
@ -100,6 +103,17 @@ const defaultForm = {
|
|||||||
phone: '',
|
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> = {
|
const statusStyles: Record<string, string> = {
|
||||||
pending: 'bg-amber-100 text-amber-800 ring-amber-200',
|
pending: 'bg-amber-100 text-amber-800 ring-amber-200',
|
||||||
sent: 'bg-sky-100 text-sky-800 ring-sky-200',
|
sent: 'bg-sky-100 text-sky-800 ring-sky-200',
|
||||||
@ -165,6 +179,11 @@ export default function ReviewFlowWorkspace() {
|
|||||||
transactions: 0,
|
transactions: 0,
|
||||||
paymentEvents: 0,
|
paymentEvents: 0,
|
||||||
};
|
};
|
||||||
|
const selectedReviewDestination =
|
||||||
|
reviewDestinationOptions.find(
|
||||||
|
(destination) => destination.key === form.reviewDestination,
|
||||||
|
) || reviewDestinationOptions[0];
|
||||||
|
const isHostedReviewDestination = !selectedReviewDestination.requiresLink;
|
||||||
|
|
||||||
const previewDate = useMemo(() => {
|
const previewDate = useMemo(() => {
|
||||||
if (!isClientReady) return 'after the selected delay';
|
if (!isClientReady) return 'after the selected delay';
|
||||||
@ -220,6 +239,7 @@ export default function ReviewFlowWorkspace() {
|
|||||||
try {
|
try {
|
||||||
const response = await axios.post('/reviewflow/request', {
|
const response = await axios.post('/reviewflow/request', {
|
||||||
...form,
|
...form,
|
||||||
|
reviewLink: isHostedReviewDestination ? '' : form.reviewLink,
|
||||||
delayDays: Number(form.delayDays),
|
delayDays: Number(form.delayDays),
|
||||||
});
|
});
|
||||||
const newRequest = response.data.request;
|
const newRequest = response.data.request;
|
||||||
@ -253,6 +273,7 @@ export default function ReviewFlowWorkspace() {
|
|||||||
setForm((current) => ({
|
setForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
businessName: connectorForm.businessName,
|
businessName: connectorForm.businessName,
|
||||||
|
reviewDestination: connectorForm.reviewDestination,
|
||||||
reviewLink: connectorForm.reviewLink,
|
reviewLink: connectorForm.reviewLink,
|
||||||
delayDays: connectorForm.delayDays,
|
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 className='grid gap-6 lg:grid-cols-[1.2fr_0.8fr] lg:items-center'>
|
||||||
<div>
|
<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'>
|
<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>
|
</p>
|
||||||
<h2 className='max-w-3xl text-4xl font-black tracking-tight md:text-5xl'>
|
<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>
|
</h2>
|
||||||
<p className='mt-4 max-w-2xl text-base text-slate-200 md:text-lg'>
|
<p className='mt-4 max-w-2xl text-base text-slate-200 md:text-lg'>
|
||||||
Connect each payment provider once. Every successful payment
|
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.
|
||||||
webhook can create a customer, save a transaction, and queue a
|
|
||||||
review request automatically.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className='grid grid-cols-2 gap-3'>
|
<div className='grid grid-cols-2 gap-3'>
|
||||||
@ -357,8 +376,8 @@ export default function ReviewFlowWorkspace() {
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<FormField
|
<FormField
|
||||||
label='Business setup'
|
label='Business and review destination'
|
||||||
help='Use the review link where customers should land.'
|
help='Choose the destination first. Shopify hosted reviews generate a Review Flow form automatically.'
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
@ -368,15 +387,42 @@ export default function ReviewFlowWorkspace() {
|
|||||||
}
|
}
|
||||||
placeholder='Business name'
|
placeholder='Business name'
|
||||||
/>
|
/>
|
||||||
<input
|
<select
|
||||||
required
|
value={form.reviewDestination}
|
||||||
type='url'
|
|
||||||
value={form.reviewLink}
|
|
||||||
onChange={(event) =>
|
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>
|
||||||
<FormField
|
<FormField
|
||||||
label='Customer'
|
label='Customer'
|
||||||
@ -560,7 +606,7 @@ export default function ReviewFlowWorkspace() {
|
|||||||
</div>
|
</div>
|
||||||
<div className='rounded-2xl bg-gradient-to-r from-indigo-600 to-emerald-500 p-4 text-white'>
|
<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'>
|
<p className='text-xs font-bold uppercase tracking-widest text-white/70'>
|
||||||
Review link
|
Review link / hosted form
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href={selected.review_link || '#'}
|
href={selected.review_link || '#'}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user