diff --git a/backend/src/db/migrations/20260629043000-add-shopify-woocommerce-webhooks.js b/backend/src/db/migrations/20260629043000-add-shopify-woocommerce-webhooks.js new file mode 100644 index 0000000..f1d3a9c --- /dev/null +++ b/backend/src/db/migrations/20260629043000-add-shopify-woocommerce-webhooks.js @@ -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; + } + }, +}; diff --git a/backend/src/db/migrations/20260629053000-add-review-destinations-and-hosted-reviews.js b/backend/src/db/migrations/20260629053000-add-review-destinations-and-hosted-reviews.js new file mode 100644 index 0000000..9b04c61 --- /dev/null +++ b/backend/src/db/migrations/20260629053000-add-review-destinations-and-hosted-reviews.js @@ -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; + } + }, +}; diff --git a/backend/src/db/models/businesses.js b/backend/src/db/models/businesses.js index 8e87011..e8751bb 100644 --- a/backend/src/db/models/businesses.js +++ b/backend/src/db/models/businesses.js @@ -158,6 +158,69 @@ paypal_webhook_token: { + }, + + +shopify_store_reference: { + type: DataTypes.TEXT, + + + + }, + +shopify_connected: { + type: DataTypes.BOOLEAN, + + allowNull: false, + defaultValue: false, + + + + }, + +shopify_connected_at: { + type: DataTypes.DATE, + + + + }, + +shopify_webhook_token: { + type: DataTypes.TEXT, + + + + }, + +woocommerce_store_reference: { + type: DataTypes.TEXT, + + + + }, + +woocommerce_connected: { + type: DataTypes.BOOLEAN, + + allowNull: false, + defaultValue: false, + + + + }, + +woocommerce_connected_at: { + type: DataTypes.DATE, + + + + }, + +woocommerce_webhook_token: { + type: DataTypes.TEXT, + + + }, default_review_platform: { @@ -187,6 +250,44 @@ custom_review_link: { + }, + + review_destination: { + type: DataTypes.TEXT, + + + + }, + + trustpilot_review_link: { + type: DataTypes.TEXT, + + + + }, + + angi_review_link: { + type: DataTypes.TEXT, + + + + }, + + opentable_review_link: { + type: DataTypes.TEXT, + + + + }, + + shopify_hosted_reviews_enabled: { + type: DataTypes.BOOLEAN, + + allowNull: false, + defaultValue: false, + + + }, importHash: { diff --git a/backend/src/db/models/customers.js b/backend/src/db/models/customers.js index 523a5fe..bf55104 100644 --- a/backend/src/db/models/customers.js +++ b/backend/src/db/models/customers.js @@ -48,6 +48,21 @@ paypal_customer_reference: { + }, + + +shopify_customer_reference: { + type: DataTypes.TEXT, + + + + }, + +woocommerce_customer_reference: { + type: DataTypes.TEXT, + + + }, contact_status: { diff --git a/backend/src/db/models/review_requests.js b/backend/src/db/models/review_requests.js index 013f336..c93a238 100644 --- a/backend/src/db/models/review_requests.js +++ b/backend/src/db/models/review_requests.js @@ -1,9 +1,3 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); - module.exports = function(sequelize, DataTypes) { const review_requests = sequelize.define( 'review_requests', @@ -113,6 +107,55 @@ tracking_token: { + }, + + review_platform: { + type: DataTypes.TEXT, + + + + }, + + review_rating: { + type: DataTypes.INTEGER, + + + + }, + + review_title: { + type: DataTypes.TEXT, + + + + }, + + review_content: { + type: DataTypes.TEXT, + + + + }, + + reviewer_display_name: { + type: DataTypes.TEXT, + + + + }, + + review_payload_json: { + type: DataTypes.TEXT, + + + + }, + + submitted_at: { + type: DataTypes.DATE, + + + }, importHash: { diff --git a/backend/src/db/models/transactions.js b/backend/src/db/models/transactions.js index 6972319..e7c921a 100644 --- a/backend/src/db/models/transactions.js +++ b/backend/src/db/models/transactions.js @@ -34,6 +34,21 @@ paypal_payment_reference: { + }, + + +shopify_order_reference: { + type: DataTypes.TEXT, + + + + }, + +woocommerce_order_reference: { + type: DataTypes.TEXT, + + + }, provider_event_reference: { diff --git a/backend/src/index.js b/backend/src/index.js index dd19b8a..2c62141 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -36,6 +36,7 @@ const review_requestsRoutes = require('./routes/review_requests'); const reviewflowRoutes = require('./routes/reviewflow'); const reviewflowWebhooksRoutes = require('./routes/reviewflow-webhooks'); +const reviewflowPublicRoutes = require('./routes/reviewflow-public'); const stripe_eventsRoutes = require('./routes/stripe_events'); @@ -119,6 +120,8 @@ app.use('/api/reviewflow', passport.authenticate('jwt', {session: false}), revie app.use('/api/reviewflow-webhooks', reviewflowWebhooksRoutes); +app.use('/api/reviewflow-public', reviewflowPublicRoutes); + app.use('/api/stripe_events', passport.authenticate('jwt', {session: false}), stripe_eventsRoutes); app.use('/api/email_delivery_logs', passport.authenticate('jwt', {session: false}), email_delivery_logsRoutes); diff --git a/backend/src/routes/reviewflow-public.js b/backend/src/routes/reviewflow-public.js new file mode 100644 index 0000000..750c83a --- /dev/null +++ b/backend/src/routes/reviewflow-public.js @@ -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; diff --git a/backend/src/routes/reviewflow-webhooks.js b/backend/src/routes/reviewflow-webhooks.js index 3687bee..6dbc02e 100644 --- a/backend/src/routes/reviewflow-webhooks.js +++ b/backend/src/routes/reviewflow-webhooks.js @@ -10,6 +10,7 @@ router.post('/:provider/:businessId/:secretToken', wrapAsync(async (req, res) => req.params.businessId, req.params.secretToken, req.body, + req.headers, ); res.status(200).send({ received: true, ...result }); diff --git a/backend/src/routes/reviewflow.js b/backend/src/routes/reviewflow.js index d9e7007..25f2fdd 100644 --- a/backend/src/routes/reviewflow.js +++ b/backend/src/routes/reviewflow.js @@ -33,6 +33,30 @@ function validateUrl(value, message) { } } +const REVIEW_LINK_FIELDS = { + google: 'google_review_link', + yelp: 'yelp_review_link', + facebook: 'facebook_review_link', + trustpilot: 'trustpilot_review_link', + angi: 'angi_review_link', + opentable: 'opentable_review_link', + custom: 'custom_review_link', +}; + +function normalizeReviewDestination(value) { + const destination = normalizeString(value).toLowerCase(); + + if (ReviewFlowService.REVIEW_CHANNELS[destination]) { + return destination; + } + + return 'google'; +} + +function getReviewLinkField(reviewDestination) { + return REVIEW_LINK_FIELDS[reviewDestination] || null; +} + function buildEmailBody(customerName, businessName, reviewLink) { const greetingName = customerName || 'there'; return [ @@ -48,6 +72,11 @@ function buildEmailBody(customerName, businessName, reviewLink) { } + +router.get('/review-channels', wrapAsync(async (req, res) => { + res.status(200).send({ channels: ReviewFlowService.serializeReviewChannels() }); +})); + router.get('/connectors', wrapAsync(async (req, res) => { const businesses = await ReviewFlowService.listConnectorBusinesses(req.currentUser, req); @@ -136,13 +165,18 @@ router.post('/request', wrapAsync(async (req, res) => { const body = req.body || {}; const businessName = normalizeString(body.businessName); const reviewLink = normalizeString(body.reviewLink); + const reviewDestination = normalizeReviewDestination(body.reviewDestination || body.reviewPlatform || 'google'); + const isHostedReviewDestination = reviewDestination === 'shopify_hosted'; + const reviewLinkField = getReviewLinkField(reviewDestination); const customerEmail = normalizeString(body.customerEmail).toLowerCase(); const customerName = normalizeString(body.customerName); const phone = normalizeString(body.phone); const delayDays = Math.max(0, Math.min(Number(body.delayDays) || 0, 30)); requireField(businessName, 'Business name is required.'); - requireField(reviewLink, 'Review link is required.'); + if (!isHostedReviewDestination) { + requireField(reviewLink, 'Review link is required.'); + } requireField(customerEmail, 'Customer email is required.'); if (!EMAIL_PATTERN.test(customerEmail)) { @@ -151,33 +185,53 @@ router.post('/request', wrapAsync(async (req, res) => { throw error; } - validateUrl(reviewLink, 'Enter a valid Google, Yelp, Facebook, or review page URL.'); + if (reviewLink) { + validateUrl(reviewLink, 'Enter a valid review destination URL.'); + } const scheduledFor = new Date(Date.now() + delayDays * 24 * 60 * 60 * 1000); + const trackingToken = crypto.randomBytes(18).toString('hex'); + const effectiveReviewLink = isHostedReviewDestination + ? ReviewFlowService.getHostedReviewUrl(req, trackingToken) + : reviewLink; const transaction = await db.sequelize.transaction(); try { + const businessDefaults = { + name: businessName, + review_destination: reviewDestination, + shopify_hosted_reviews_enabled: isHostedReviewDestination, + delay_days: delayDays, + email_subject_template: `How was your experience with ${businessName}?`, + email_body_template: buildEmailBody('{customerName}', businessName, '{reviewLink}'), + is_active: true, + createdById: currentUser.id, + updatedById: currentUser.id, + }; + + if (reviewLink && reviewLinkField) { + businessDefaults[reviewLinkField] = reviewLink; + } + const [business] = await db.businesses.findOrCreate({ where: { name: businessName, createdById: currentUser.id }, - defaults: { - name: businessName, - google_review_link: reviewLink, - delay_days: delayDays, - email_subject_template: `How was your experience with ${businessName}?`, - email_body_template: buildEmailBody('{customerName}', businessName, '{reviewLink}'), - is_active: true, - createdById: currentUser.id, - updatedById: currentUser.id, - }, + defaults: businessDefaults, transaction, }); - await business.update({ - google_review_link: reviewLink, + const businessUpdates = { + review_destination: reviewDestination, + shopify_hosted_reviews_enabled: business.shopify_hosted_reviews_enabled || isHostedReviewDestination, delay_days: delayDays, is_active: true, updatedById: currentUser.id, - }, { transaction }); + }; + + if (reviewLink && reviewLinkField) { + businessUpdates[reviewLinkField] = reviewLink; + } + + await business.update(businessUpdates, { transaction }); const [customer] = await db.customers.findOrCreate({ where: { email: customerEmail, createdById: currentUser.id }, @@ -206,9 +260,10 @@ router.post('/request', wrapAsync(async (req, res) => { status: 'pending', scheduled_for: scheduledFor, email_subject: emailSubject, - email_body: buildEmailBody(customerName, businessName, reviewLink), - review_link: reviewLink, - tracking_token: crypto.randomBytes(18).toString('hex'), + email_body: buildEmailBody(customerName, businessName, effectiveReviewLink), + review_link: effectiveReviewLink, + tracking_token: trackingToken, + review_platform: reviewDestination, businessId: business.id, customerId: customer.id, createdById: currentUser.id, diff --git a/backend/src/services/reviewflow.js b/backend/src/services/reviewflow.js index 209becc..34fad8b 100644 --- a/backend/src/services/reviewflow.js +++ b/backend/src/services/reviewflow.js @@ -24,6 +24,8 @@ const ZERO_DECIMAL_CURRENCIES = new Set([ const PROVIDERS = { stripe: { label: 'Stripe', + category: 'payment_trigger', + defaultReviewDestination: 'google', accountField: 'stripe_account_reference', connectedField: 'stripe_connected', connectedAtField: 'stripe_connected_at', @@ -33,6 +35,8 @@ const PROVIDERS = { }, square: { label: 'Square', + category: 'payment_trigger', + defaultReviewDestination: 'google', accountField: 'square_account_reference', connectedField: 'square_connected', connectedAtField: 'square_connected_at', @@ -42,6 +46,8 @@ const PROVIDERS = { }, paypal: { label: 'PayPal', + category: 'payment_trigger', + defaultReviewDestination: 'google', accountField: 'paypal_merchant_reference', connectedField: 'paypal_connected', connectedAtField: 'paypal_connected_at', @@ -49,6 +55,104 @@ const PROVIDERS = { paymentReferenceField: 'paypal_payment_reference', customerReferenceField: 'paypal_customer_reference', }, + shopify: { + label: 'Shopify', + category: 'ecommerce_order_trigger', + defaultReviewDestination: 'shopify_hosted', + hostedReviewProvider: true, + accountField: 'shopify_store_reference', + connectedField: 'shopify_connected', + connectedAtField: 'shopify_connected_at', + tokenField: 'shopify_webhook_token', + paymentReferenceField: 'shopify_order_reference', + customerReferenceField: 'shopify_customer_reference', + }, + woocommerce: { + label: 'WooCommerce', + category: 'ecommerce_order_trigger', + defaultReviewDestination: 'trustpilot', + accountField: 'woocommerce_store_reference', + connectedField: 'woocommerce_connected', + connectedAtField: 'woocommerce_connected_at', + tokenField: 'woocommerce_webhook_token', + paymentReferenceField: 'woocommerce_order_reference', + customerReferenceField: 'woocommerce_customer_reference', + }, +}; + +const REVIEW_CHANNELS = { + google: { + key: 'google', + label: 'Google', + category: 'local_review_destination', + mode: 'external_link', + linkField: 'google_review_link', + requiresExternalLink: true, + helperText: 'Send local customers to the Google review link for the business profile.', + }, + facebook: { + key: 'facebook', + label: 'Facebook', + category: 'local_review_destination', + mode: 'external_link', + linkField: 'facebook_review_link', + requiresExternalLink: true, + helperText: 'Send local customers to the Facebook recommendations/reviews link.', + }, + yelp: { + key: 'yelp', + label: 'Yelp', + category: 'local_review_destination', + mode: 'external_link', + linkField: 'yelp_review_link', + requiresExternalLink: true, + helperText: 'Send local-service customers to the Yelp business review link.', + }, + angi: { + key: 'angi', + label: 'Angi', + category: 'local_service_review_destination', + mode: 'external_link', + linkField: 'angi_review_link', + requiresExternalLink: true, + helperText: 'Send home-service customers to the Angi profile/review link.', + }, + opentable: { + key: 'opentable', + label: 'OpenTable', + category: 'local_hospitality_review_destination', + mode: 'external_link', + linkField: 'opentable_review_link', + requiresExternalLink: true, + helperText: 'Send restaurant guests to the OpenTable restaurant/review link.', + }, + trustpilot: { + key: 'trustpilot', + label: 'Trustpilot', + category: 'ecommerce_review_destination', + mode: 'external_link', + linkField: 'trustpilot_review_link', + requiresExternalLink: true, + helperText: 'Send ecommerce customers to the Trustpilot review invitation or business link.', + }, + shopify_hosted: { + key: 'shopify_hosted', + label: 'Shopify hosted product review', + category: 'ecommerce_review_destination', + mode: 'hosted_form', + linkField: null, + requiresExternalLink: false, + helperText: 'Use Review Flow’s hosted product-review form after a Shopify paid order.', + }, + custom: { + key: 'custom', + label: 'Custom review page', + category: 'custom_review_destination', + mode: 'external_link', + linkField: 'custom_review_link', + requiresExternalLink: true, + helperText: 'Send customers to a custom review page you control.', + }, }; function normalizeString(value) { @@ -59,6 +163,50 @@ function normalizeEmail(value) { return normalizeString(value).toLowerCase(); } +function normalizeHeaderValue(value) { + if (Array.isArray(value)) { + return normalizeString(value[0]); + } + + return normalizeString(value); +} + +function getNormalizedReviewDestination(value) { + const normalizedDestination = normalizeString(value).toLowerCase(); + + if (REVIEW_CHANNELS[normalizedDestination]) { + return normalizedDestination; + } + + return 'google'; +} + +function getReviewChannel(destination) { + return REVIEW_CHANNELS[getNormalizedReviewDestination(destination)]; +} + +function getReviewLinkField(destination) { + return getReviewChannel(destination).linkField; +} + +function getReviewDestination(business) { + return getNormalizedReviewDestination( + business?.review_destination || business?.default_review_platform || 'google', + ); +} + +function getReviewDestinationForPayment(payment, business) { + if (payment?.provider === 'shopify' && business?.shopify_hosted_reviews_enabled !== false) { + return 'shopify_hosted'; + } + + return getReviewDestination(business); +} + +function serializeReviewChannels() { + return Object.values(REVIEW_CHANNELS); +} + function httpError(message, code) { const error = new Error(message); error.code = code; @@ -87,7 +235,7 @@ function getProviderConfig(provider) { const config = PROVIDERS[normalizedProvider]; if (!config) { - throw httpError('Unsupported payment provider. Use stripe, square, or paypal.', 400); + throw httpError('Unsupported webhook provider. Use stripe, square, paypal, shopify, or woocommerce.', 400); } return { provider: normalizedProvider, ...config }; @@ -102,13 +250,43 @@ function getOwnerId(business) { } function getRequestOrigin(req) { - const forwardedProto = normalizeString(req.headers['x-forwarded-proto']).split(',')[0]; + const forwardedProto = normalizeHeaderValue(req.headers['x-forwarded-proto']).split(',')[0]; const proto = forwardedProto || req.protocol || 'https'; - const host = req.get('host'); + const forwardedHost = normalizeHeaderValue(req.headers['x-forwarded-host']).split(',')[0]; + const host = forwardedHost || req.get('host'); return `${proto}://${host}`; } +function getRequestOriginFromHeaders(headers = {}) { + const forwardedProto = normalizeHeaderValue(headers['x-forwarded-proto']).split(',')[0]; + const proto = forwardedProto || 'https'; + const forwardedHost = normalizeHeaderValue(headers['x-forwarded-host']).split(',')[0]; + const host = forwardedHost || normalizeHeaderValue(headers.host); + + return host ? `${proto}://${host}` : ''; +} + +function getHostedReviewUrlFromOrigin(origin, trackingToken) { + if (!trackingToken) { + return ''; + } + + if (!origin) { + return `/review/${trackingToken}`; + } + + return `${origin}/review/${trackingToken}`; +} + +function getHostedReviewUrl(req, trackingToken) { + return getHostedReviewUrlFromOrigin(getRequestOrigin(req), trackingToken); +} + +function getHostedReviewUrlFromHeaders(headers, trackingToken) { + return getHostedReviewUrlFromOrigin(getRequestOriginFromHeaders(headers), trackingToken); +} + function getWebhookUrl(req, business, provider) { const config = getProviderConfig(provider); const token = business[config.tokenField]; @@ -120,22 +298,27 @@ function getWebhookUrl(req, business, provider) { return `${getRequestOrigin(req)}/api/reviewflow-webhooks/${config.provider}/${business.id}/${token}`; } -function getReviewLink(business) { - const platform = business.default_review_platform || 'google'; +function getReviewLink(business, options = {}) { + const destination = getNormalizedReviewDestination( + options.reviewDestination || getReviewDestination(business), + ); + const channel = getReviewChannel(destination); - if (platform === 'custom') { - return business.custom_review_link || business.google_review_link || business.yelp_review_link || business.facebook_review_link || ''; + if (channel.mode === 'hosted_form') { + if (options.req) { + return getHostedReviewUrl(options.req, options.trackingToken); + } + + return getHostedReviewUrlFromHeaders(options.headers, options.trackingToken); } - if (platform === 'yelp') { - return business.yelp_review_link || business.google_review_link || business.facebook_review_link || business.custom_review_link || ''; + const directLink = channel.linkField ? business[channel.linkField] : ''; + + if (directLink) { + return directLink; } - if (platform === 'facebook') { - return business.facebook_review_link || business.google_review_link || business.yelp_review_link || business.custom_review_link || ''; - } - - return business.google_review_link || business.custom_review_link || business.yelp_review_link || business.facebook_review_link || ''; + return business.custom_review_link || business.google_review_link || business.yelp_review_link || business.facebook_review_link || business.trustpilot_review_link || business.angi_review_link || business.opentable_review_link || ''; } function buildEmailBody(customerName, businessName, reviewLink) { @@ -305,6 +488,123 @@ function normalizePaypalEvent(payload) { }; } +function normalizeShopifyEvent(payload, headers = {}) { + const eventType = normalizeString( + headers['x-shopify-topic'] || + payload?.topic || + payload?.event_type || + payload?.type || + 'orders/paid', + ); + const currency = normalizeString( + payload?.currency || + payload?.presentment_currency || + payload?.current_total_price_set?.shop_money?.currency_code || + payload?.total_price_set?.shop_money?.currency_code || + '', + ).toUpperCase(); + const customer = payload?.customer || {}; + const billing = payload?.billing_address || {}; + const shipping = payload?.shipping_address || {}; + const financialStatus = normalizeString(payload?.financial_status).toLowerCase(); + const orderReference = normalizeString( + payload?.admin_graphql_api_id || + payload?.id || + payload?.order_number || + payload?.name, + ); + const eventReference = normalizeString( + headers['x-shopify-webhook-id'] || + payload?.webhook_id || + payload?.id || + `${eventType}-${orderReference}-${payload?.updated_at || payload?.created_at || ''}`, + ); + const customerName = normalizeString( + [ + customer.first_name || billing.first_name || shipping.first_name, + customer.last_name || billing.last_name || shipping.last_name, + ].filter(Boolean).join(' '), + ); + const products = Array.isArray(payload?.line_items) + ? payload.line_items.map((item) => ({ + name: normalizeString(item?.name || item?.title), + productId: normalizeString(item?.product_id ? String(item.product_id) : item?.admin_graphql_api_id), + variantId: normalizeString(item?.variant_id ? String(item.variant_id) : item?.variant_title), + sku: normalizeString(item?.sku), + quantity: Number(item?.quantity) || null, + })).filter((product) => product.name || product.productId || product.variantId).slice(0, 10) + : []; + + return { + eventReference, + providerEventType: eventType, + normalizedEventType: normalizeEventType('shopify', eventType, payload), + paymentReference: orderReference, + amount: decimalAmount( + payload?.current_total_price || + payload?.total_price || + payload?.subtotal_price, + ), + currency, + email: normalizeEmail( + payload?.email || + payload?.contact_email || + customer.email || + billing.email, + ), + customerName, + phone: normalizeString(payload?.phone || customer.phone || billing.phone || shipping.phone), + customerReference: normalizeString(customer.admin_graphql_api_id || customer.id || payload?.customer_id), + description: normalizeString(payload?.name || payload?.order_number || payload?.checkout_id || eventType), + paidAt: toDate(payload?.processed_at || payload?.created_at || payload?.updated_at), + reviewContext: { + provider: 'shopify', + order: { + id: orderReference, + name: normalizeString(payload?.name || payload?.order_number), + orderNumber: normalizeString(payload?.order_number ? String(payload.order_number) : ''), + }, + products, + }, + isPaymentSuccess: eventType === 'orders/paid' || financialStatus === 'paid', + }; +} + +function normalizeWooCommerceEvent(payload, headers = {}) { + const eventType = normalizeString( + headers['x-wc-webhook-topic'] || + payload?.topic || + payload?.event_type || + payload?.type || + 'order.updated', + ); + const billing = payload?.billing || {}; + const orderStatus = normalizeString(payload?.status).toLowerCase(); + const orderReference = normalizeString(payload?.id || payload?.order_key || payload?.number || payload?.transaction_id); + const eventReference = normalizeString( + headers['x-wc-webhook-delivery-id'] || + headers['x-wc-webhook-id'] || + payload?.webhook_id || + `${eventType}-${orderReference}-${payload?.date_modified_gmt || payload?.date_modified || payload?.date_created || ''}`, + ); + + return { + eventReference, + providerEventType: eventType, + normalizedEventType: normalizeEventType('woocommerce', eventType, payload), + paymentReference: orderReference, + amount: decimalAmount(payload?.total), + currency: normalizeString(payload?.currency || '').toUpperCase(), + email: normalizeEmail(billing.email), + customerName: normalizeString([billing.first_name, billing.last_name].filter(Boolean).join(' ')), + phone: normalizeString(billing.phone), + customerReference: normalizeString(payload?.customer_id), + description: normalizeString(payload?.number || payload?.order_key || payload?.transaction_id || eventType), + paidAt: toDate(payload?.date_paid_gmt || payload?.date_paid || payload?.date_created_gmt || payload?.date_created), + isPaymentSuccess: ['processing', 'completed'].includes(orderStatus), + }; +} + function normalizeEventType(provider, providerEventType, payment) { const eventType = normalizeString(providerEventType); const status = normalizeString(payment?.status).toLowerCase(); @@ -328,10 +628,26 @@ function normalizeEventType(provider, providerEventType, payment) { if (eventType.includes('COMPLETED')) return 'payment_intent_succeeded'; } + if (provider === 'shopify') { + const financialStatus = normalizeString(payment?.financial_status).toLowerCase(); + + if (financialStatus === 'refunded' || eventType.includes('refund')) return 'charge_refunded'; + if (['voided', 'expired', 'declined'].includes(financialStatus)) return 'charge_failed'; + if (eventType === 'orders/paid' || financialStatus === 'paid') return 'payment_intent_succeeded'; + } + + if (provider === 'woocommerce') { + const orderStatus = normalizeString(payment?.status).toLowerCase(); + + if (['refunded'].includes(orderStatus) || eventType.includes('refund')) return 'charge_refunded'; + if (['failed', 'cancelled'].includes(orderStatus)) return 'charge_failed'; + if (['processing', 'completed'].includes(orderStatus)) return 'payment_intent_succeeded'; + } + return 'unknown'; } -function normalizePaymentEvent(provider, payload) { +function normalizePaymentEvent(provider, payload, headers = {}) { if (provider === 'stripe') { return normalizeStripeEvent(payload); } @@ -340,7 +656,15 @@ function normalizePaymentEvent(provider, payload) { return normalizeSquareEvent(payload); } - return normalizePaypalEvent(payload); + if (provider === 'paypal') { + return normalizePaypalEvent(payload); + } + + if (provider === 'shopify') { + return normalizeShopifyEvent(payload, headers); + } + + return normalizeWooCommerceEvent(payload, headers); } function serializeBusiness(req, business) { @@ -350,8 +674,13 @@ function serializeBusiness(req, business) { google_review_link: business.google_review_link, yelp_review_link: business.yelp_review_link, facebook_review_link: business.facebook_review_link, + trustpilot_review_link: business.trustpilot_review_link, + angi_review_link: business.angi_review_link, + opentable_review_link: business.opentable_review_link, custom_review_link: business.custom_review_link, default_review_platform: business.default_review_platform, + review_destination: getReviewDestination(business), + shopify_hosted_reviews_enabled: Boolean(business.shopify_hosted_reviews_enabled), delay_days: business.delay_days, providers: Object.keys(PROVIDERS).map((providerKey) => { const config = getProviderConfig(providerKey); @@ -360,6 +689,9 @@ function serializeBusiness(req, business) { return { key: providerKey, label: config.label, + category: config.category, + default_review_destination: config.defaultReviewDestination, + hosted_review_provider: Boolean(config.hostedReviewProvider), connected: Boolean(business[config.connectedField]), connected_at: business[config.connectedAtField] || null, account_reference: business[config.accountField] || '', @@ -387,6 +719,10 @@ async function connectProvider(currentUser, body, req) { const businessName = normalizeString(body.businessName); const reviewLink = normalizeString(body.reviewLink); const accountReference = normalizeString(body.accountReference); + const reviewDestination = getNormalizedReviewDestination( + body.reviewDestination || body.defaultReviewPlatform || body.reviewPlatform || config.defaultReviewDestination, + ); + const reviewLinkField = getReviewLinkField(reviewDestination); const delayDays = Math.max(0, Math.min(Number(body.delayDays) || 0, 30)); let business; @@ -406,9 +742,10 @@ async function connectProvider(currentUser, body, req) { } if (!business) { - business = await db.businesses.create({ + const createPayload = { name: businessName, - google_review_link: reviewLink || null, + review_destination: reviewDestination, + shopify_hosted_reviews_enabled: Boolean(config.hostedReviewProvider || reviewDestination === 'shopify_hosted'), delay_days: delayDays, email_subject_template: `How was your experience with ${businessName}?`, email_body_template: buildEmailBody('{customerName}', businessName, '{reviewLink}'), @@ -416,12 +753,22 @@ async function connectProvider(currentUser, body, req) { createdById: currentUser.id, updatedById: currentUser.id, ownerId: currentUser.id, - }); + }; + + if (reviewLink && reviewLinkField) { + createPayload[reviewLinkField] = reviewLink; + } + + business = await db.businesses.create(createPayload); } const updates = { is_active: true, delay_days: delayDays, + review_destination: reviewDestination, + shopify_hosted_reviews_enabled: Boolean( + business.shopify_hosted_reviews_enabled || config.hostedReviewProvider || reviewDestination === 'shopify_hosted', + ), updatedById: currentUser.id, [config.connectedField]: true, [config.connectedAtField]: new Date(), @@ -433,8 +780,8 @@ async function connectProvider(currentUser, body, req) { updates.name = businessName; } - if (reviewLink) { - updates.google_review_link = reviewLink; + if (reviewLink && reviewLinkField) { + updates[reviewLinkField] = reviewLink; } await business.update(updates); @@ -521,6 +868,8 @@ async function createTransactionFromPayment(payment, business, customer, transac stripe_payment_reference: payment.provider === 'stripe' ? payment.paymentReference || null : null, square_payment_reference: payment.provider === 'square' ? payment.paymentReference || null : null, paypal_payment_reference: payment.provider === 'paypal' ? payment.paymentReference || null : null, + shopify_order_reference: payment.provider === 'shopify' ? payment.paymentReference || null : null, + woocommerce_order_reference: payment.provider === 'woocommerce' ? payment.paymentReference || null : null, provider_event_reference: payment.eventReference || null, amount: payment.amount, currency: payment.currency || null, @@ -537,9 +886,24 @@ async function createTransactionFromPayment(payment, business, customer, transac return { transactionRecord, duplicate: false }; } -async function createReviewRequestFromPayment(payment, business, customer, transactionRecord, transaction) { +function safeJsonStringify(value) { + if (!value) { + return null; + } + + return JSON.stringify(value); +} + +async function createReviewRequestFromPayment(payment, business, customer, transactionRecord, transaction, headers = {}) { const ownerId = getOwnerId(business); - const reviewLink = getReviewLink(business); + const trackingToken = crypto.randomBytes(18).toString('hex'); + const reviewDestination = getReviewDestinationForPayment(payment, business); + const reviewLink = getReviewLink(business, { + headers, + provider: payment.provider, + reviewDestination, + trackingToken, + }); if (!payment.isPaymentSuccess) { return null; @@ -570,7 +934,9 @@ async function createReviewRequestFromPayment(payment, business, customer, trans email_subject: emailSubject, email_body: emailBody, review_link: reviewLink, - tracking_token: crypto.randomBytes(18).toString('hex'), + tracking_token: trackingToken, + review_platform: reviewDestination, + review_payload_json: safeJsonStringify(payment.reviewContext), businessId: business.id, customerId: customer.id, transactionId: transactionRecord.id, @@ -579,7 +945,7 @@ async function createReviewRequestFromPayment(payment, business, customer, trans }, { transaction }); } -async function processPaymentWebhook(providerName, businessId, secretToken, payload) { +async function processPaymentWebhook(providerName, businessId, secretToken, payload, headers = {}) { const { provider, ...config } = getProviderConfig(providerName); const business = await db.businesses.findByPk(businessId); @@ -593,7 +959,7 @@ async function processPaymentWebhook(providerName, businessId, secretToken, payl const payment = { provider, - ...normalizePaymentEvent(provider, payload || {}), + ...normalizePaymentEvent(provider, payload || {}, headers), }; const ownerId = getOwnerId(business); const existingEvent = payment.eventReference ? await db.stripe_events.findOne({ @@ -642,6 +1008,7 @@ async function processPaymentWebhook(providerName, businessId, secretToken, payl customer, transactionRecord, transaction, + headers, ); let processingError = null; @@ -683,15 +1050,141 @@ async function processPaymentWebhook(providerName, businessId, secretToken, payl } } + +function parseReviewPayloadJson(value) { + if (!value) { + return null; + } + + try { + return JSON.parse(value); + } catch (error) { + console.error('Failed to parse hosted review payload JSON:', error); + return null; + } +} + +function serializeHostedReviewRequest(reviewRequest) { + const reviewPayload = parseReviewPayloadJson(reviewRequest.review_payload_json); + + return { + id: reviewRequest.id, + status: reviewRequest.status, + scheduled_for: reviewRequest.scheduled_for, + reviewed_at: reviewRequest.reviewed_at, + submitted_at: reviewRequest.submitted_at, + review_platform: reviewRequest.review_platform, + review_rating: reviewRequest.review_rating, + review_title: reviewRequest.review_title, + review_content: reviewRequest.review_content, + reviewer_display_name: reviewRequest.reviewer_display_name, + review_payload: reviewPayload, + business: reviewRequest.business ? { + id: reviewRequest.business.id, + name: reviewRequest.business.name, + } : null, + customer: reviewRequest.customer ? { + name: reviewRequest.customer.name, + email: reviewRequest.customer.email, + } : null, + transaction: reviewRequest.transaction ? { + id: reviewRequest.transaction.id, + payment_provider: reviewRequest.transaction.payment_provider, + amount: reviewRequest.transaction.amount, + currency: reviewRequest.transaction.currency, + paid_at: reviewRequest.transaction.paid_at, + description: reviewRequest.transaction.description, + } : null, + }; +} + +async function findHostedReviewRequest(trackingToken) { + const token = normalizeString(trackingToken); + + requireField(token, 'Review token is required.'); + + const reviewRequest = await db.review_requests.findOne({ + where: { tracking_token: token }, + include: [ + { model: db.businesses, as: 'business' }, + { model: db.customers, as: 'customer' }, + { model: db.transactions, as: 'transaction' }, + ], + }); + + if (!reviewRequest) { + throw httpError('Review request was not found or has expired.', 404); + } + + return reviewRequest; +} + +async function getHostedReviewRequest(trackingToken) { + const reviewRequest = await findHostedReviewRequest(trackingToken); + + if (!reviewRequest.clicked_at && reviewRequest.status !== 'reviewed') { + await reviewRequest.update({ + clicked_at: new Date(), + status: ['pending', 'sent', 'opened'].includes(reviewRequest.status) ? 'clicked' : reviewRequest.status, + }); + } + + return serializeHostedReviewRequest(reviewRequest); +} + +async function submitHostedReview(trackingToken, body) { + const reviewRequest = await findHostedReviewRequest(trackingToken); + const rating = Number(body?.rating ?? body?.review_rating); + const reviewTitle = normalizeString(body?.title || body?.review_title).slice(0, 200); + const reviewContent = normalizeString(body?.content || body?.review_content).slice(0, 5000); + const reviewerDisplayName = normalizeString(body?.reviewerName || body?.reviewer_display_name).slice(0, 120); + + if (!Number.isInteger(rating) || rating < 1 || rating > 5) { + throw httpError('Choose a rating from 1 to 5 stars.', 400); + } + + if (!reviewContent) { + throw httpError('Review text is required.', 400); + } + + const reviewPayload = parseReviewPayloadJson(reviewRequest.review_payload_json) || {}; + reviewPayload.hostedReview = { + submittedAt: new Date().toISOString(), + source: 'hosted_review_form', + }; + + await reviewRequest.update({ + status: 'reviewed', + clicked_at: reviewRequest.clicked_at || new Date(), + reviewed_at: new Date(), + submitted_at: new Date(), + review_rating: rating, + review_title: reviewTitle || null, + review_content: reviewContent, + reviewer_display_name: reviewerDisplayName || reviewRequest.customer?.name || null, + review_payload_json: safeJsonStringify(reviewPayload), + }); + + const refreshedReviewRequest = await findHostedReviewRequest(trackingToken); + return serializeHostedReviewRequest(refreshedReviewRequest); +} + module.exports = { PROVIDERS, + REVIEW_CHANNELS, buildEmailBody, connectProvider, generateWebhookToken, + getHostedReviewRequest, + getHostedReviewUrl, getProviderConfig, + getReviewDestination, + getReviewLink, getWebhookUrl, listConnectorBusinesses, processPaymentWebhook, rotateWebhookToken, serializeBusiness, + serializeReviewChannels, + submitHostedReview, }; diff --git a/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx b/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx index eab9f67..e3b50d2 100644 --- a/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx +++ b/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx @@ -13,8 +13,11 @@ import CardBox from '../CardBox'; import FormField from '../FormField'; export interface ProviderConnector { - key: 'stripe' | 'square' | 'paypal' | string; + key: 'stripe' | 'square' | 'paypal' | 'shopify' | 'woocommerce' | string; label: string; + category?: string; + default_review_destination?: string; + hosted_review_provider?: boolean; connected: boolean; connected_at?: string | null; account_reference?: string; @@ -27,6 +30,14 @@ export interface ConnectorBusiness { id: string; name?: string; google_review_link?: string; + yelp_review_link?: string; + facebook_review_link?: string; + trustpilot_review_link?: string; + angi_review_link?: string; + opentable_review_link?: string; + custom_review_link?: string; + review_destination?: string; + shopify_hosted_reviews_enabled?: boolean; delay_days?: number; providers: ProviderConnector[]; } @@ -34,6 +45,7 @@ export interface ConnectorBusiness { export interface ConnectorFormValues { provider: string; businessName: string; + reviewDestination: string; reviewLink: string; delayDays: string; accountReference: string; @@ -53,6 +65,7 @@ interface PaymentProviderConnectorsProps { const connectorDefaults: ConnectorFormValues = { provider: 'stripe', businessName: 'Review Flow Studio', + reviewDestination: 'google', reviewLink: 'https://g.page/r/example/review', delayDays: '7', accountReference: '', @@ -62,18 +75,110 @@ const providerOptions = [ { key: 'stripe', label: 'Stripe', + categoryLabel: 'Payment trigger', + defaultReviewDestination: 'google', description: 'Connect card and checkout payments from Stripe.', }, { key: 'paypal', label: 'PayPal', + categoryLabel: 'Payment trigger', + defaultReviewDestination: 'google', description: 'Connect completed PayPal captures and sales.', }, { key: 'square', label: 'Square', + categoryLabel: 'Payment trigger', + defaultReviewDestination: 'google', description: 'Connect Square payment notifications.', }, + { + key: 'shopify', + label: 'Shopify', + categoryLabel: 'Ecommerce order trigger + hosted reviews', + defaultReviewDestination: 'shopify_hosted', + description: 'Connect paid Shopify orders; customers review products on a hosted Review Flow form.', + }, + { + key: 'woocommerce', + label: 'WooCommerce', + categoryLabel: 'Ecommerce order trigger', + defaultReviewDestination: 'trustpilot', + description: 'Connect WooCommerce orders from your WordPress store.', + }, +]; + +const reviewDestinationOptions = [ + { + key: 'google', + label: 'Google', + group: 'Local review destinations', + mode: 'external_link', + description: 'For local businesses collecting Google profile reviews.', + }, + { + key: 'facebook', + label: 'Facebook', + group: 'Local review destinations', + mode: 'external_link', + description: 'For local Facebook recommendations and reviews.', + }, + { + key: 'yelp', + label: 'Yelp', + group: 'Local review destinations', + mode: 'external_link', + description: 'For local-service Yelp review requests.', + }, + { + key: 'angi', + label: 'Angi', + group: 'Local review destinations', + mode: 'external_link', + description: 'For home-service Angi profile review requests.', + }, + { + key: 'opentable', + label: 'OpenTable', + group: 'Local review destinations', + mode: 'external_link', + description: 'For restaurant guests leaving OpenTable reviews.', + }, + { + key: 'shopify_hosted', + label: 'Shopify hosted product review', + group: 'Ecommerce review destinations', + mode: 'hosted_form', + description: 'Review Flow hosts the product review form after a Shopify paid order.', + }, + { + key: 'trustpilot', + label: 'Trustpilot', + group: 'Ecommerce review destinations', + mode: 'external_link', + description: 'For ecommerce brand/store review invitations.', + }, + { + key: 'custom', + label: 'Custom review page', + group: 'Custom destination', + mode: 'external_link', + description: 'Use any review page you control.', + }, +]; + +const reviewDestinationGroups = [ + { + title: 'Local review destinations', + subtitle: 'Google, Facebook, Yelp, Angi, and OpenTable send customers to the correct local profile/review page.', + keys: ['google', 'facebook', 'yelp', 'angi', 'opentable'], + }, + { + title: 'Ecommerce review destinations', + subtitle: 'Shopify uses Review Flow hosted product reviews. Trustpilot stays separate as an ecommerce review link destination.', + keys: ['shopify_hosted', 'trustpilot'], + }, ]; const providerInstructions: Record = { @@ -92,6 +197,16 @@ const providerInstructions: Record = { 'Paste this Review Flow webhook URL as the webhook URL.', 'Send PAYMENT.CAPTURE.COMPLETED or PAYMENT.SALE.COMPLETED events.', ], + shopify: [ + 'Shopify admin → Settings → Notifications → Webhooks → Create webhook.', + 'Choose JSON format, paste this Review Flow webhook URL, and save the webhook.', + 'Start with orders/paid. Review Flow will generate a hosted product-review page for each paid order.', + ], + woocommerce: [ + 'WordPress admin → WooCommerce → Settings → Advanced → Webhooks → Add webhook.', + 'Set status to Active, paste this Review Flow webhook URL as the delivery URL, and save.', + 'Create order.created and order.updated webhooks so paid status changes can queue reviews.', + ], }; const providerSetupDetails: Record< @@ -156,14 +271,54 @@ const providerSetupDetails: Record< testTip: 'A completed PayPal capture, sale, or checkout order should appear as a payment event before a review request is queued.', }, + shopify: { + dashboardPath: + 'Shopify admin → Settings → Notifications → Webhooks → Create webhook', + requiredEvents: ['orders/paid', 'orders/create', 'orders/fulfilled'], + steps: [ + 'Select Shopify in Review Flow, enter the business name and delay days, and click Connect Shopify.', + 'Copy the generated Review Flow webhook URL from the connected account card.', + 'In Shopify admin, open Settings, then Notifications, then create a webhook in the Webhooks section.', + 'Choose the Order payment / orders/paid event, select JSON format, paste the copied URL, and save the webhook.', + 'Review Flow will create a customer, transaction, and hosted product-review form link for each paid order with an email.', + 'Use Shopify test notifications or place a test paid order, then return here and click Refresh connectors.', + ], + testTip: + 'A paid Shopify order queues a hosted Review Flow product review when the payload includes a customer email and financial_status is paid.', + }, + woocommerce: { + dashboardPath: + 'WordPress admin → WooCommerce → Settings → Advanced → Webhooks → Add webhook', + requiredEvents: ['order.created', 'order.updated'], + steps: [ + 'Select WooCommerce in Review Flow, enter the business name, review link, delay days, and click Connect WooCommerce.', + 'Copy the generated Review Flow webhook URL from the connected account card.', + 'In WordPress admin, open WooCommerce, then Settings, then Advanced, then Webhooks, and click Add webhook.', + 'Name the webhook Review Flow, set Status to Active, choose the Order created topic, and paste the copied URL into Delivery URL.', + 'Save the webhook, then add a second Active webhook for Order updated so status changes to processing or completed are captured.', + 'Create or update a test order to processing/completed, then return here and click Refresh connectors.', + ], + testTip: + 'A WooCommerce order queues a review when status is processing or completed and the billing email is present.', + }, }; const providerGradient: Record = { stripe: 'from-indigo-600 to-violet-600', square: 'from-emerald-600 to-teal-600', paypal: 'from-sky-600 to-blue-700', + shopify: 'from-lime-600 to-emerald-700', + woocommerce: 'from-purple-700 to-fuchsia-700', }; +function getProviderCardTitle(provider: ProviderConnector) { + if (provider.key === 'shopify' || provider.hosted_review_provider) { + return 'Order trigger + hosted review form'; + } + + return 'Webhook receiver'; +} + function formatDate(value?: string | null) { if (!value) return 'Not scheduled'; @@ -188,9 +343,9 @@ function isUnauthorizedError(error: unknown) { export default function PaymentProviderConnectors({ className = '', - eyebrow = 'Payment webhooks', - title = 'Connect Stripe, PayPal, and Square', - description = 'Connect each payment provider once. Successful payment webhooks can create customers, save transactions, and queue review requests automatically.', + eyebrow = 'Order triggers and review destinations', + title = 'Connect payment/ecommerce triggers without mixing local review channels', + description = 'Payment and ecommerce providers trigger review requests. Review destinations decide where customers leave feedback: local profiles, ecommerce review links, or the hosted Shopify product-review form.', onConnected, }: PaymentProviderConnectorsProps) { const [connectorForm, setConnectorForm] = @@ -207,6 +362,16 @@ export default function PaymentProviderConnectors({ providerOptions.find( (provider) => provider.key === connectorForm.provider, ) || providerOptions[0]; + const effectiveReviewDestination = + selectedProvider.defaultReviewDestination === 'shopify_hosted' + ? 'shopify_hosted' + : connectorForm.reviewDestination; + const selectedReviewDestination = + reviewDestinationOptions.find( + (destination) => destination.key === effectiveReviewDestination, + ) || reviewDestinationOptions[0]; + const isHostedReviewDestination = + selectedReviewDestination.mode === 'hosted_form'; const connectorPreviewDate = useMemo(() => { if (!isClientReady) return 'after the selected delay'; @@ -238,6 +403,21 @@ export default function PaymentProviderConnectors({ setConnectorForm((current) => ({ ...current, [key]: value })); }; + const updateSelectedProvider = (providerKey: string) => { + const provider = + providerOptions.find((providerOption) => providerOption.key === providerKey) || + providerOptions[0]; + + setConnectorForm((current) => ({ + ...current, + provider: provider.key, + reviewDestination: + provider.defaultReviewDestination === 'shopify_hosted' + ? 'shopify_hosted' + : current.reviewDestination, + })); + }; + const loadConnectors = async () => { setIsConnectorLoading(true); try { @@ -277,20 +457,25 @@ export default function PaymentProviderConnectors({ setError(''); try { - const response = await axios.post('/reviewflow/connectors', { + const submittedConnectorForm = { ...connectorForm, + reviewDestination: effectiveReviewDestination, + reviewLink: isHostedReviewDestination ? '' : connectorForm.reviewLink, + }; + const response = await axios.post('/reviewflow/connectors', { + ...submittedConnectorForm, delayDays: Number(connectorForm.delayDays), }); const business = response.data.business as ConnectorBusiness; setConnectorMessage( - `${selectedProvider.label} is connected for ${business.name}. Copy the secure webhook URL below into your ${selectedProvider.label} dashboard.`, + `${selectedProvider.label} is connected for ${business.name}. ${selectedReviewDestination.label} is the review destination. Copy the secure webhook URL below into your ${selectedProvider.label} dashboard.`, ); await loadConnectors(); if (onConnected) { try { - await onConnected(business, connectorForm); + await onConnected(business, submittedConnectorForm); } catch (refreshError) { console.error( 'Payment connector post-connect refresh failed:', @@ -380,9 +565,10 @@ export default function PaymentProviderConnectors({ /> Secure connection note - Payment providers must POST to a public webhook URL. These endpoints - are protected by the long secret token embedded in each generated URL. - Rotate a URL if it is ever exposed. + Payment and ecommerce providers POST order/payment events to a public webhook URL. + Local review destinations do not use these webhooks; they are the places customers + visit after a request. Shopify is the exception here: it triggers from orders and + Review Flow hosts the product-review form. @@ -397,7 +583,44 @@ export default function PaymentProviderConnectors({ )} -
+
+ {reviewDestinationGroups.map((group) => ( +
+

+ Review destination group +

+

+ {group.title} +

+

+ {group.subtitle} +

+
+ {group.keys.map((destinationKey) => { + const destination = reviewDestinationOptions.find( + (option) => option.key === destinationKey, + ); + + if (!destination) return null; + + return ( + + {destination.label} + + ); + })} +
+
+ ))} +
+ +
{providerOptions.map((provider) => { const isSelected = connectorForm.provider === provider.key; @@ -405,7 +628,7 @@ export default function PaymentProviderConnectors({
-
+
{business.providers.map((provider) => (
- Webhook receiver + {getProviderCardTitle(provider)}

- Setup steps + Provider setup steps

    {(providerInstructions[provider.key] || []).map( diff --git a/frontend/src/pages/connect.tsx b/frontend/src/pages/connect.tsx index 4235d96..81f61c0 100644 --- a/frontend/src/pages/connect.tsx +++ b/frontend/src/pages/connect.tsx @@ -28,16 +28,13 @@ export default function ConnectPage() {

    - Payment provider setup + Trigger setup · destination clarity

    - Connect Stripe, PayPal, and Square. + Connect order/payment triggers without confusing local review channels.

    - Use this page to generate secure webhook URLs for each provider. - Once connected, successful payments can flow into Review Flow and - automatically create customers, transactions, and review - requests. + Use this page to generate secure webhook URLs for payment and ecommerce triggers. Review destinations stay separate: local businesses use Google, Facebook, Yelp, Angi, or OpenTable links, ecommerce brands can use Trustpilot, and Shopify can use a hosted product-review form.

    @@ -46,9 +43,7 @@ export default function ConnectPage() {

    How connection works

    - Pick a provider, enter the business review settings, then copy - the generated webhook URL into that provider dashboard. You can - rotate URLs anytime if a secret is exposed. + Pick the order/payment trigger first, then choose where reviews should land. Shopify is both an ecommerce trigger and a hosted Review Flow product-review destination.

@@ -56,8 +51,8 @@ export default function ConnectPage() { diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index aed2939..72520c7 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -9,12 +9,12 @@ import { getPageTitle } from '../config'; const metrics = [ ['7 days', 'default review delay'], - ['3 sources', 'Stripe, Square, PayPal'], + ['5 sources', 'Stripe, Square, PayPal, Shopify, WooCommerce'], ['4 states', 'pending, sent, clicked, reviewed'], ]; const steps = [ - ['Capture', 'Receive Stripe, Square, or PayPal payment webhooks as soon as checkout happens.'], + ['Capture', 'Receive Stripe, Square, PayPal, Shopify, or WooCommerce webhooks as soon as checkout happens.'], ['Schedule', 'Create the customer, transaction, and review request automatically with your preferred delay.'], ['Track', 'Follow pending, sent, clicked, and reviewed requests from one workspace.'], ]; @@ -65,7 +65,7 @@ export default function Starter() { Ask at the perfect moment. Earn more five-star reviews.

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

diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index fd4468b..3a3403c 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -20,7 +20,7 @@ import { findMe, loginUser, resetAction } from '../stores/authSlice'; import { useAppDispatch, useAppSelector } from '../stores/hooks'; import Link from 'next/link'; import {toast, ToastContainer} from "react-toastify"; -import { getPexelsImage, getPexelsVideo } from '../helpers/pexels' +import { getPexelsImage } from '../helpers/pexels' export default function Login() { const router = useRouter(); @@ -33,9 +33,7 @@ export default function Login() { photographer: undefined, photographer_url: undefined, }) - const [ illustrationVideo, setIllustrationVideo ] = useState({video_files: []}) - const [contentType, setContentType] = useState('video'); - const [contentPosition, setContentPosition] = useState('left'); + const [contentPosition] = useState<'left' | 'right' | 'background'>('left'); const [showPassword, setShowPassword] = useState(false); const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector( (state) => state.auth, @@ -46,13 +44,135 @@ export default function Login() { const title = 'Review Flow' - // Fetch Pexels image/video + const appHighlights = [ + 'Automated review requests after payments, jobs, or service milestones.', + 'Customer, business, transaction, and delivery follow-up data in one admin workspace.', + 'Dashboards, CRM records, payment events, email logs, and admin controls already built in.', + ]; + + const competitorAdvantages = [ + { + title: 'Built around review operations', + description: + 'Review Flow combines CRM records, payments, follow-up, review requests, and reputation workflows in one focused system.', + }, + { + title: 'Designed for logistics teams', + description: + 'Transportation teams can manage businesses, customers, transactions, payment events, and review requests without jumping tools.', + }, + { + title: 'More value in the base plan', + description: + 'The $65 Base plan includes review automation, widgets, social proof, analytics, AI replies, referrals, and the app tools already available.', + }, + ]; + + const pricingPlans = [ + { + name: 'Base', + price: '$65', + description: + 'Best for businesses that want full review growth tools plus the core Review Flow admin system.', + sections: [ + { + title: 'Review automation', + features: [ + 'Automate review requests and follow-up reminders.', + 'Manually send review requests.', + 'Personalize review request SMS and email messaging.', + 'Personalize review invite links.', + 'Monitor reviews across the web.', + 'New review notifications and opportunities reports.', + ], + }, + { + title: 'Widgets, referrals, and social proof', + features: [ + 'Showcase reviews on your website with social proof widgets.', + 'Collect reviews and leads with widgets for your website.', + 'Microsite that showcases your reviews and generates leads.', + 'Automate sharing of reviews to your social media accounts.', + 'Share referral link on social media.', + ], + }, + { + title: 'Insights, AI, and team motivation', + features: [ + 'Easily respond to customer reviews with AI-generated replies.', + 'Gain review insights and trending topics.', + 'Campaign insights and analytics.', + 'Encourage friendly competition with staff leaderboards.', + 'Connect to 1000s of business apps.', + ], + }, + { + title: 'Existing Review Flow tools included', + features: [ + 'Review Flow workspace for creating, scheduling, and tracking review requests.', + 'Business, customer, transaction, and delivery follow-up records.', + 'Webhook connectors for Stripe, PayPal, Square, Shopify, and WooCommerce workflows.', + 'Payment events, email delivery logs, and cron run monitoring.', + 'Admin dashboard with users, roles, permissions, profile, and API documentation access.', + ], + }, + ], + }, + { + name: 'Pro', + price: '$99', + description: + 'Best for growing teams that want every Base feature plus booking, referral, gifting, competitor, and advanced AI tools.', + sections: [ + { + title: 'Everything in Base', + features: [ + 'Includes all Base review automation, widgets, referrals, analytics, AI replies, social sharing, integrations, and existing app tools.', + 'Advanced workflow management.', + 'Priority setup support.', + ], + }, + { + title: 'Booking reminders', + features: [ + 'Automate repeat booking reminders and follow-ups.', + 'Personalize booking reminder SMS and email messaging.', + ], + }, + { + title: 'Referral automation', + features: [ + 'Automate customer referral requests and follow-ups.', + 'Personalize referral request SMS and email messaging.', + 'Personalize referral invite links.', + ], + }, + { + title: 'Gifting and loyalty', + features: [ + 'Delight your loyal customers with gift automations.', + 'Automate gifting for new customers.', + ], + }, + { + title: 'Competitor intelligence and advanced feedback', + features: [ + 'Gain competitor review and SEO insights.', + 'Track competitor topics and gain valuable competitive intel.', + 'Competitor topic insights include topics for your business.', + 'Automate review replies with AI.', + 'Collect deeper, more actionable customer feedback with NPS Surveys.', + ], + }, + ], + }, + ]; + + // Fetch Pexels image useEffect( () => { async function fetchData() { const image = await getPexelsImage() - const video = await getPexelsVideo() setIllustrationImage(image); - setIllustrationVideo(video); } fetchData(); }, []); @@ -115,32 +235,7 @@ export default function Login() {
) - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- - -
) - } - }; + return (
- {contentType === 'image' && contentPosition !== 'background' ? imageBlock(illustrationImage) : null} - {contentType === 'video' && contentPosition !== 'background' ? videoBlock(illustrationVideo) : null} + {contentPosition !== 'background' ? imageBlock(illustrationImage) : null}
@@ -257,6 +351,95 @@ export default function Login() { + + +
+
+

About Us

+

+ Review management built for transportation teams. +

+

+ 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. +

+
+ +
+ {appHighlights.map((highlight) => ( +
+ {highlight} +
+ ))} +
+ +
+

Why we're better

+
+ {competitorAdvantages.map((item) => ( +
+
{item.title}
+

+ {item.description} +

+
+ ))} +
+
+ +
+
+
+

Pricing

+

Simple monthly plans

+
+

Upgrade when your review workflow grows.

+
+ +
+ {pricingPlans.map((plan) => ( +
+
+
+
{plan.name}
+

{plan.description}

+
+
+ {plan.price} + /month +
+
+
+ {plan.sections.map((section) => ( +
+
+ {section.title} +
+
    + {section.features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+
+ ))} +
+
+ ))} +
+
+
+
diff --git a/frontend/src/pages/review/[trackingToken].tsx b/frontend/src/pages/review/[trackingToken].tsx new file mode 100644 index 0000000..7fb83c0 --- /dev/null +++ b/frontend/src/pages/review/[trackingToken].tsx @@ -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(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) => { + 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 ( + <> + + {getPageTitle(`Review ${businessName}`)} + +
+
+
+

+ Review Flow +

+

+ Share your experience with {businessName} +

+

+ Your feedback helps the team improve and helps future customers know what to expect. +

+
+ + + {isLoading ? ( +
+ Loading your review form... +
+ ) : error ? ( +
+

We could not load this review request.

+

{error}

+ +
+ ) : isSubmitted ? ( +
+ +

Thank you for your review!

+

+ Your feedback was submitted successfully. +

+ {review?.review_rating && ( +
+ {ratingOptions.map((option) => ( + {option <= Number(review.review_rating || 0) ? '★' : '☆'} + ))} +
+ )} +
+ ) : ( +
+
+

+ Review context +

+

+ {businessName} +

+ {(orderName || amount || review?.transaction?.payment_provider) && ( +

+ {review?.transaction?.payment_provider || 'Order'} + {orderName ? ` · ${orderName}` : ''} + {amount ? ` · ${amount}` : ''} +

+ )} + {products.length > 0 && ( +
+ {products.map((product, index) => ( +
+

+ {product.name || 'Purchased item'} +

+

+ {product.sku ? `SKU ${product.sku}` : 'Shopify product'} + {product.quantity ? ` · Qty ${product.quantity}` : ''} +

+
+ ))} +
+ )} +
+ +
+ +
+ {ratingOptions.map((option) => ( + + ))} +
+
+ +
+ + 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} + /> +
+ +
+ +