This commit is contained in:
Flatlogic Bot 2026-06-29 19:07:34 +00:00
parent c7ec13b78b
commit f741ab0364
57 changed files with 1107 additions and 678 deletions

View File

@ -8,6 +8,7 @@ const {
const DEFAULT_PLAN_ID = 'starter'; const DEFAULT_PLAN_ID = 'starter';
const DEFAULT_STATUS = 'trialing'; const DEFAULT_STATUS = 'trialing';
const INTERNAL_ADMIN_ROLE_NAMES = ['Administrator'];
const DAY_IN_MS = 24 * 60 * 60 * 1000; const DAY_IN_MS = 24 * 60 * 60 * 1000;
const PAYMENT_CONNECTOR_FIELDS = [ const PAYMENT_CONNECTOR_FIELDS = [
'stripe_connected', 'stripe_connected',
@ -103,6 +104,38 @@ function getEffectiveSubscription(user, referenceDate = new Date()) {
}; };
} }
function getUserRoleName(user) {
return user?.app_role?.name || user?.app_role?.dataValues?.name || '';
}
async function isSubscriptionLimitExemptUser(user, options = {}) {
if (!user) {
return false;
}
if (user.email === 'admin@flatlogic.com' || user.dataValues?.email === 'admin@flatlogic.com') {
return true;
}
const roleName = getUserRoleName(user);
if (INTERNAL_ADMIN_ROLE_NAMES.includes(roleName)) {
return true;
}
const roleId = user.app_roleId || user.dataValues?.app_roleId;
if (!roleId) {
return false;
}
const role = await db.roles.findByPk(roleId, {
transaction: options.transaction || undefined,
});
return INTERNAL_ADMIN_ROLE_NAMES.includes(role?.name);
}
function getLimitMessage(plan, usageCount, limit, unit, options = {}) { function getLimitMessage(plan, usageCount, limit, unit, options = {}) {
const baseMessage = `${plan.name} includes ${limit.toLocaleString()} ${unit}. You have already used ${usageCount.toLocaleString()}.`; const baseMessage = `${plan.name} includes ${limit.toLocaleString()} ${unit}. You have already used ${usageCount.toLocaleString()}.`;
const upgradePrefix = plan.id === 'starter' ? 'Upgrade to Pro or ' : ''; const upgradePrefix = plan.id === 'starter' ? 'Upgrade to Pro or ' : '';
@ -568,6 +601,15 @@ module.exports = class SubscriptionService {
const user = await getUserRecord(currentUserOrId, options); const user = await getUserRecord(currentUserOrId, options);
const subscription = getEffectiveSubscription(user); const subscription = getEffectiveSubscription(user);
if (await isSubscriptionLimitExemptUser(user, options)) {
return {
allowed: true,
usage: null,
subscription,
subscriptionExempt: true,
};
}
if (!subscription.isActive) { if (!subscription.isActive) {
return { return {
allowed: false, allowed: false,
@ -610,11 +652,20 @@ module.exports = class SubscriptionService {
const user = await getUserRecord(currentUserOrId, options); const user = await getUserRecord(currentUserOrId, options);
const subscription = getEffectiveSubscription(user); const subscription = getEffectiveSubscription(user);
if (await isSubscriptionLimitExemptUser(user, options)) {
return {
allowed: true,
usage: null,
subscription,
subscriptionExempt: true,
};
}
if (!subscription.isActive) { if (!subscription.isActive) {
return { return {
allowed: false, allowed: false,
code: 403, code: 403,
message: 'Your Review Flow trial has ended. Choose a plan to keep adding businesses.', message: 'Your Review Flow trial has ended. Choose a plan to keep adding business profiles.',
}; };
} }
@ -629,8 +680,12 @@ module.exports = class SubscriptionService {
subscription.plan, subscription.plan,
usage.businesses, usage.businesses,
limit, limit,
'businesses/locations', limit === 1 ? 'business profile' : 'business profiles',
{ remediation: 'remove an existing business/location before adding another.' }, {
remediation: limit === 1
? 'remove your existing business profile before adding another.'
: 'remove an existing business profile before adding another.',
},
), ),
}; };
} }
@ -652,6 +707,15 @@ module.exports = class SubscriptionService {
const user = await getUserRecord(currentUserOrId, options); const user = await getUserRecord(currentUserOrId, options);
const subscription = getEffectiveSubscription(user); const subscription = getEffectiveSubscription(user);
if (await isSubscriptionLimitExemptUser(user, options)) {
return {
allowed: true,
usage: null,
subscription,
subscriptionExempt: true,
};
}
if (!subscription.isActive) { if (!subscription.isActive) {
return { return {
allowed: false, allowed: false,
@ -694,6 +758,15 @@ module.exports = class SubscriptionService {
const user = await getUserRecord(currentUserOrId, options); const user = await getUserRecord(currentUserOrId, options);
const subscription = getEffectiveSubscription(user); const subscription = getEffectiveSubscription(user);
if (await isSubscriptionLimitExemptUser(user, options)) {
return {
allowed: true,
usage: null,
subscription,
subscriptionExempt: true,
};
}
if (!subscription.isActive) { if (!subscription.isActive) {
return { return {
allowed: false, allowed: false,
@ -740,6 +813,10 @@ module.exports = class SubscriptionService {
const user = await getUserRecord(currentUserOrId, options); const user = await getUserRecord(currentUserOrId, options);
const subscription = getEffectiveSubscription(user); const subscription = getEffectiveSubscription(user);
if (await isSubscriptionLimitExemptUser(user, options)) {
return true;
}
if (!subscription.isActive) { if (!subscription.isActive) {
throw httpError('Your Review Flow trial has ended. Choose a plan to keep using this feature.', 403); throw httpError('Your Review Flow trial has ended. Choose a plan to keep using this feature.', 403);
} }

View File

@ -19,7 +19,7 @@ const subscriptionPlans = [
'Manual review request creation', 'Manual review request creation',
'Hosted public review form', 'Hosted public review form',
'Customer management', 'Customer management',
'Business/location management', 'Business profile management',
'Transaction tracking', 'Transaction tracking',
'Stripe, Square, PayPal, Shopify, and WooCommerce webhook intake', 'Stripe, Square, PayPal, Shopify, and WooCommerce webhook intake',
'Review request status tracking', 'Review request status tracking',

View File

@ -90,7 +90,7 @@ const CardBusinesses = ({
<div className='flex justify-between gap-x-4 py-3'> <div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>BusinessName</dt> <dt className=' text-gray-500 dark:text-dark-600'>Business name</dt>
<dd className='flex items-start gap-x-2'> <dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'> <div className='font-medium line-clamp-4'>
{ item.name } { item.name }
@ -102,7 +102,7 @@ const CardBusinesses = ({
<div className='flex justify-between gap-x-4 py-3'> <div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>GoogleReviewLink</dt> <dt className=' text-gray-500 dark:text-dark-600'>Google review link</dt>
<dd className='flex items-start gap-x-2'> <dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'> <div className='font-medium line-clamp-4'>
{ item.google_review_link } { item.google_review_link }
@ -114,7 +114,7 @@ const CardBusinesses = ({
<div className='flex justify-between gap-x-4 py-3'> <div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>YelpReviewLink</dt> <dt className=' text-gray-500 dark:text-dark-600'>Yelp review link</dt>
<dd className='flex items-start gap-x-2'> <dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'> <div className='font-medium line-clamp-4'>
{ item.yelp_review_link } { item.yelp_review_link }
@ -126,7 +126,7 @@ const CardBusinesses = ({
<div className='flex justify-between gap-x-4 py-3'> <div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>FacebookReviewLink</dt> <dt className=' text-gray-500 dark:text-dark-600'>Facebook review link</dt>
<dd className='flex items-start gap-x-2'> <dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'> <div className='font-medium line-clamp-4'>
{ item.facebook_review_link } { item.facebook_review_link }
@ -138,7 +138,7 @@ const CardBusinesses = ({
<div className='flex justify-between gap-x-4 py-3'> <div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>DelayDays</dt> <dt className=' text-gray-500 dark:text-dark-600'>Review delay days</dt>
<dd className='flex items-start gap-x-2'> <dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'> <div className='font-medium line-clamp-4'>
{ item.delay_days } { item.delay_days }
@ -150,7 +150,7 @@ const CardBusinesses = ({
<div className='flex justify-between gap-x-4 py-3'> <div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>EmailSubjectTemplate</dt> <dt className=' text-gray-500 dark:text-dark-600'>Email subject template</dt>
<dd className='flex items-start gap-x-2'> <dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'> <div className='font-medium line-clamp-4'>
{ item.email_subject_template } { item.email_subject_template }
@ -162,7 +162,7 @@ const CardBusinesses = ({
<div className='flex justify-between gap-x-4 py-3'> <div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>EmailBodyTemplate</dt> <dt className=' text-gray-500 dark:text-dark-600'>Email body template</dt>
<dd className='flex items-start gap-x-2'> <dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'> <div className='font-medium line-clamp-4'>
{ item.email_body_template } { item.email_body_template }
@ -174,7 +174,7 @@ const CardBusinesses = ({
<div className='flex justify-between gap-x-4 py-3'> <div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>IsActive</dt> <dt className=' text-gray-500 dark:text-dark-600'>Active</dt>
<dd className='flex items-start gap-x-2'> <dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'> <div className='font-medium line-clamp-4'>
{ dataFormatter.booleanFormatter(item.is_active) } { dataFormatter.booleanFormatter(item.is_active) }
@ -186,7 +186,7 @@ const CardBusinesses = ({
<div className='flex justify-between gap-x-4 py-3'> <div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>StripeAccountReference</dt> <dt className=' text-gray-500 dark:text-dark-600'>Stripe account reference</dt>
<dd className='flex items-start gap-x-2'> <dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'> <div className='font-medium line-clamp-4'>
{ item.stripe_account_reference } { item.stripe_account_reference }
@ -198,7 +198,7 @@ const CardBusinesses = ({
<div className='flex justify-between gap-x-4 py-3'> <div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>StripeConnected</dt> <dt className=' text-gray-500 dark:text-dark-600'>Stripe connected</dt>
<dd className='flex items-start gap-x-2'> <dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'> <div className='font-medium line-clamp-4'>
{ dataFormatter.booleanFormatter(item.stripe_connected) } { dataFormatter.booleanFormatter(item.stripe_connected) }
@ -210,7 +210,7 @@ const CardBusinesses = ({
<div className='flex justify-between gap-x-4 py-3'> <div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>StripeConnectedAt</dt> <dt className=' text-gray-500 dark:text-dark-600'>Stripe connected at</dt>
<dd className='flex items-start gap-x-2'> <dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'> <div className='font-medium line-clamp-4'>
{ dataFormatter.dateTimeFormatter(item.stripe_connected_at) } { dataFormatter.dateTimeFormatter(item.stripe_connected_at) }
@ -222,7 +222,7 @@ const CardBusinesses = ({
<div className='flex justify-between gap-x-4 py-3'> <div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>DefaultReviewPlatform</dt> <dt className=' text-gray-500 dark:text-dark-600'>Default review platform</dt>
<dd className='flex items-start gap-x-2'> <dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'> <div className='font-medium line-clamp-4'>
{ item.default_review_platform } { item.default_review_platform }
@ -234,7 +234,7 @@ const CardBusinesses = ({
<div className='flex justify-between gap-x-4 py-3'> <div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>CustomReviewLink</dt> <dt className=' text-gray-500 dark:text-dark-600'>Custom review link</dt>
<dd className='flex items-start gap-x-2'> <dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'> <div className='font-medium line-clamp-4'>
{ item.custom_review_link } { item.custom_review_link }

View File

@ -56,7 +56,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
<div className={'flex-1 px-3'}> <div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>BusinessName</p> <p className={'text-xs text-gray-500 '}>Business name</p>
<p className={'line-clamp-2'}>{ item.name }</p> <p className={'line-clamp-2'}>{ item.name }</p>
</div> </div>
@ -64,7 +64,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
<div className={'flex-1 px-3'}> <div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>GoogleReviewLink</p> <p className={'text-xs text-gray-500 '}>Google review link</p>
<p className={'line-clamp-2'}>{ item.google_review_link }</p> <p className={'line-clamp-2'}>{ item.google_review_link }</p>
</div> </div>
@ -72,7 +72,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
<div className={'flex-1 px-3'}> <div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>YelpReviewLink</p> <p className={'text-xs text-gray-500 '}>Yelp review link</p>
<p className={'line-clamp-2'}>{ item.yelp_review_link }</p> <p className={'line-clamp-2'}>{ item.yelp_review_link }</p>
</div> </div>
@ -80,7 +80,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
<div className={'flex-1 px-3'}> <div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>FacebookReviewLink</p> <p className={'text-xs text-gray-500 '}>Facebook review link</p>
<p className={'line-clamp-2'}>{ item.facebook_review_link }</p> <p className={'line-clamp-2'}>{ item.facebook_review_link }</p>
</div> </div>
@ -88,7 +88,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
<div className={'flex-1 px-3'}> <div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>DelayDays</p> <p className={'text-xs text-gray-500 '}>Review delay days</p>
<p className={'line-clamp-2'}>{ item.delay_days }</p> <p className={'line-clamp-2'}>{ item.delay_days }</p>
</div> </div>
@ -96,7 +96,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
<div className={'flex-1 px-3'}> <div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>EmailSubjectTemplate</p> <p className={'text-xs text-gray-500 '}>Email subject template</p>
<p className={'line-clamp-2'}>{ item.email_subject_template }</p> <p className={'line-clamp-2'}>{ item.email_subject_template }</p>
</div> </div>
@ -104,7 +104,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
<div className={'flex-1 px-3'}> <div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>EmailBodyTemplate</p> <p className={'text-xs text-gray-500 '}>Email body template</p>
<p className={'line-clamp-2'}>{ item.email_body_template }</p> <p className={'line-clamp-2'}>{ item.email_body_template }</p>
</div> </div>
@ -112,7 +112,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
<div className={'flex-1 px-3'}> <div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>IsActive</p> <p className={'text-xs text-gray-500 '}>Active</p>
<p className={'line-clamp-2'}>{ dataFormatter.booleanFormatter(item.is_active) }</p> <p className={'line-clamp-2'}>{ dataFormatter.booleanFormatter(item.is_active) }</p>
</div> </div>
@ -120,7 +120,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
<div className={'flex-1 px-3'}> <div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>StripeAccountReference</p> <p className={'text-xs text-gray-500 '}>Stripe account reference</p>
<p className={'line-clamp-2'}>{ item.stripe_account_reference }</p> <p className={'line-clamp-2'}>{ item.stripe_account_reference }</p>
</div> </div>
@ -128,7 +128,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
<div className={'flex-1 px-3'}> <div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>StripeConnected</p> <p className={'text-xs text-gray-500 '}>Stripe connected</p>
<p className={'line-clamp-2'}>{ dataFormatter.booleanFormatter(item.stripe_connected) }</p> <p className={'line-clamp-2'}>{ dataFormatter.booleanFormatter(item.stripe_connected) }</p>
</div> </div>
@ -136,7 +136,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
<div className={'flex-1 px-3'}> <div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>StripeConnectedAt</p> <p className={'text-xs text-gray-500 '}>Stripe connected at</p>
<p className={'line-clamp-2'}>{ dataFormatter.dateTimeFormatter(item.stripe_connected_at) }</p> <p className={'line-clamp-2'}>{ dataFormatter.dateTimeFormatter(item.stripe_connected_at) }</p>
</div> </div>
@ -144,7 +144,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
<div className={'flex-1 px-3'}> <div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>DefaultReviewPlatform</p> <p className={'text-xs text-gray-500 '}>Default review platform</p>
<p className={'line-clamp-2'}>{ item.default_review_platform }</p> <p className={'line-clamp-2'}>{ item.default_review_platform }</p>
</div> </div>
@ -152,7 +152,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
<div className={'flex-1 px-3'}> <div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>CustomReviewLink</p> <p className={'text-xs text-gray-500 '}>Custom review link</p>
<p className={'line-clamp-2'}>{ item.custom_review_link }</p> <p className={'line-clamp-2'}>{ item.custom_review_link }</p>
</div> </div>

View File

@ -65,7 +65,7 @@ export const loadColumns = async (
{ {
field: 'name', field: 'name',
headerName: 'BusinessName', headerName: 'Business name',
flex: 1, flex: 1,
minWidth: 120, minWidth: 120,
filterable: false, filterable: false,
@ -80,7 +80,7 @@ export const loadColumns = async (
{ {
field: 'google_review_link', field: 'google_review_link',
headerName: 'GoogleReviewLink', headerName: 'Google review link',
flex: 1, flex: 1,
minWidth: 120, minWidth: 120,
filterable: false, filterable: false,
@ -95,7 +95,7 @@ export const loadColumns = async (
{ {
field: 'yelp_review_link', field: 'yelp_review_link',
headerName: 'YelpReviewLink', headerName: 'Yelp review link',
flex: 1, flex: 1,
minWidth: 120, minWidth: 120,
filterable: false, filterable: false,
@ -110,7 +110,7 @@ export const loadColumns = async (
{ {
field: 'facebook_review_link', field: 'facebook_review_link',
headerName: 'FacebookReviewLink', headerName: 'Facebook review link',
flex: 1, flex: 1,
minWidth: 120, minWidth: 120,
filterable: false, filterable: false,
@ -125,7 +125,7 @@ export const loadColumns = async (
{ {
field: 'delay_days', field: 'delay_days',
headerName: 'DelayDays', headerName: 'Review delay days',
flex: 1, flex: 1,
minWidth: 120, minWidth: 120,
filterable: false, filterable: false,
@ -141,7 +141,7 @@ export const loadColumns = async (
{ {
field: 'email_subject_template', field: 'email_subject_template',
headerName: 'EmailSubjectTemplate', headerName: 'Email subject template',
flex: 1, flex: 1,
minWidth: 120, minWidth: 120,
filterable: false, filterable: false,
@ -156,7 +156,7 @@ export const loadColumns = async (
{ {
field: 'email_body_template', field: 'email_body_template',
headerName: 'EmailBodyTemplate', headerName: 'Email body template',
flex: 1, flex: 1,
minWidth: 120, minWidth: 120,
filterable: false, filterable: false,
@ -171,7 +171,7 @@ export const loadColumns = async (
{ {
field: 'is_active', field: 'is_active',
headerName: 'IsActive', headerName: 'Active',
flex: 1, flex: 1,
minWidth: 120, minWidth: 120,
filterable: false, filterable: false,
@ -187,7 +187,7 @@ export const loadColumns = async (
{ {
field: 'stripe_account_reference', field: 'stripe_account_reference',
headerName: 'StripeAccountReference', headerName: 'Stripe account reference',
flex: 1, flex: 1,
minWidth: 120, minWidth: 120,
filterable: false, filterable: false,
@ -202,7 +202,7 @@ export const loadColumns = async (
{ {
field: 'stripe_connected', field: 'stripe_connected',
headerName: 'StripeConnected', headerName: 'Stripe connected',
flex: 1, flex: 1,
minWidth: 120, minWidth: 120,
filterable: false, filterable: false,
@ -218,7 +218,7 @@ export const loadColumns = async (
{ {
field: 'stripe_connected_at', field: 'stripe_connected_at',
headerName: 'StripeConnectedAt', headerName: 'Stripe connected at',
flex: 1, flex: 1,
minWidth: 120, minWidth: 120,
filterable: false, filterable: false,
@ -236,7 +236,7 @@ export const loadColumns = async (
{ {
field: 'default_review_platform', field: 'default_review_platform',
headerName: 'DefaultReviewPlatform', headerName: 'Default review platform',
flex: 1, flex: 1,
minWidth: 120, minWidth: 120,
filterable: false, filterable: false,
@ -251,7 +251,7 @@ export const loadColumns = async (
{ {
field: 'custom_review_link', field: 'custom_review_link',
headerName: 'CustomReviewLink', headerName: 'Custom review link',
flex: 1, flex: 1,
minWidth: 120, minWidth: 120,
filterable: false, filterable: false,

View File

@ -11,6 +11,7 @@ import { setDarkMode } from '../stores/styleSlice'
import { logoutUser } from '../stores/authSlice' import { logoutUser } from '../stores/authSlice'
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import ClickOutside from "./ClickOutside"; import ClickOutside from "./ClickOutside";
import { getPortalLabel } from '../helpers/portalRoles';
type Props = { type Props = {
item: MenuNavBarItem item: MenuNavBarItem
@ -29,7 +30,9 @@ export default function NavBarItem({ item }: Props) {
const currentUser = useAppSelector((state) => state.auth.currentUser); const currentUser = useAppSelector((state) => state.auth.currentUser);
const userName = `${currentUser?.firstName ? currentUser?.firstName : ""} ${currentUser?.lastName ? currentUser?.lastName : ""}`; const userName = `${currentUser?.firstName ? currentUser?.firstName : ""} ${currentUser?.lastName ? currentUser?.lastName : ""}`.trim();
const userDisplayName = userName || currentUser?.email || '';
const portalLabel = currentUser ? getPortalLabel(currentUser) : '';
const [isDropdownActive, setIsDropdownActive] = useState(false) const [isDropdownActive, setIsDropdownActive] = useState(false)
@ -46,7 +49,7 @@ export default function NavBarItem({ item }: Props) {
item.isDesktopNoLabel ? 'lg:w-16 lg:justify-center' : '', item.isDesktopNoLabel ? 'lg:w-16 lg:justify-center' : '',
].join(' ') ].join(' ')
const itemLabel = item.isCurrentUser ? userName : item.label const itemLabel = item.isCurrentUser ? userDisplayName : item.label
const handleMenuClick = () => { const handleMenuClick = () => {
if (item.menu) { if (item.menu) {
@ -91,7 +94,12 @@ export default function NavBarItem({ item }: Props) {
item.isDesktopNoLabel && item.icon ? 'lg:hidden' : '' item.isDesktopNoLabel && item.icon ? 'lg:hidden' : ''
}`} }`}
> >
{itemLabel} {item.isCurrentUser ? (
<span className='flex flex-col leading-tight'>
<span>{itemLabel}</span>
<span className='text-[10px] uppercase tracking-wider opacity-70'>{portalLabel}</span>
</span>
) : itemLabel}
</span> </span>
{item.isCurrentUser && <UserAvatarCurrentUser className="w-6 h-6 mr-3 inline-flex" />} {item.isCurrentUser && <UserAvatarCurrentUser className="w-6 h-6 mr-3 inline-flex" />}
{item.menu && ( {item.menu && (

View File

@ -11,6 +11,7 @@ import React, { FormEvent, useEffect, useMemo, useState } from 'react';
import BaseButton from '../BaseButton'; import BaseButton from '../BaseButton';
import CardBox from '../CardBox'; import CardBox from '../CardBox';
import FormField from '../FormField'; import FormField from '../FormField';
import { getBusinessProfileUsageLabel } from '../../helpers/businessPlanLabels';
export interface ProviderConnector { export interface ProviderConnector {
key: 'stripe' | 'square' | 'paypal' | 'shopify' | 'woocommerce' | string; key: 'stripe' | 'square' | 'paypal' | 'shopify' | 'woocommerce' | string;
@ -858,8 +859,8 @@ export default function PaymentProviderConnectors({
<p className='mt-2 text-sm leading-6'> <p className='mt-2 text-sm leading-6'>
{isConnectorSubscriptionInactive {isConnectorSubscriptionInactive
? 'Provider connections are paused until this account has an active plan.' ? 'Provider connections are paused until this account has an active plan.'
: `${subscriptionStatus?.subscription.planName} currently uses ${connectorUsage.toLocaleString()} / ${connectorLimit.toLocaleString()} provider connectors and ${businessUsage.toLocaleString()} / ${businessLimit.toLocaleString()} businesses/locations.`} : `${subscriptionStatus?.subscription.planName} currently uses ${connectorUsage.toLocaleString()} / ${connectorLimit.toLocaleString()} provider connectors and ${getBusinessProfileUsageLabel(businessUsage, businessLimit)}.`}
{' '}Updating an already connected provider may still work, but new providers or new businesses can be blocked. {' '}Updating an already connected provider may still work, but new providers or new business profiles can be blocked.
</p> </p>
<BaseButton <BaseButton
href='/subscription' href='/subscription'

View File

@ -3,6 +3,8 @@ import axios from 'axios'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import BaseButton from './BaseButton' import BaseButton from './BaseButton'
import CardBox from './CardBox' import CardBox from './CardBox'
import { useAppSelector } from '../stores/hooks'
import { isInternalAdmin } from '../helpers/portalRoles'
type LimitKey = 'monthlyReviewRequests' | 'businesses' | 'teamMembers' | 'paymentConnectors' type LimitKey = 'monthlyReviewRequests' | 'businesses' | 'teamMembers' | 'paymentConnectors'
@ -27,7 +29,7 @@ type Props = {
const defaultLabels: Record<LimitKey, string> = { const defaultLabels: Record<LimitKey, string> = {
monthlyReviewRequests: 'review requests this month', monthlyReviewRequests: 'review requests this month',
businesses: 'businesses/locations', businesses: 'business profiles',
teamMembers: 'team members', teamMembers: 'team members',
paymentConnectors: 'connected payment providers', paymentConnectors: 'connected payment providers',
} }
@ -43,6 +45,7 @@ export default function SubscriptionLimitGate({
className = 'mb-6', className = 'mb-6',
nearLimitPercent = 80, nearLimitPercent = 80,
}: Props) { }: Props) {
const { currentUser } = useAppSelector((state) => state.auth)
const [status, setStatus] = useState<SubscriptionLimitStatus | null>(null) const [status, setStatus] = useState<SubscriptionLimitStatus | null>(null)
const [error, setError] = useState('') const [error, setError] = useState('')
@ -66,12 +69,20 @@ export default function SubscriptionLimitGate({
} }
} }
if (!currentUser || isInternalAdmin(currentUser)) {
setStatus(null)
setError('')
return () => {
isMounted = false
}
}
loadStatus() loadStatus()
return () => { return () => {
isMounted = false isMounted = false
} }
}, []) }, [currentUser])
if (error) { if (error) {
return ( return (
@ -89,7 +100,7 @@ export default function SubscriptionLimitGate({
const used = Number(status.usage[limitKey]) || 0 const used = Number(status.usage[limitKey]) || 0
const limit = Number(status.limits[limitKey]) || 0 const limit = Number(status.limits[limitKey]) || 0
const limitLabel = label || (limit === 1 && limitKey === 'businesses' const limitLabel = label || (limit === 1 && limitKey === 'businesses'
? 'business/location' ? 'business profile'
: defaultLabels[limitKey]) : defaultLabels[limitKey])
const percent = limit > 0 ? Math.round((used / limit) * 100) : 0 const percent = limit > 0 ? Math.round((used / limit) * 100) : 0
const isInactive = !status.subscription.isActive const isInactive = !status.subscription.isActive

View File

@ -0,0 +1,26 @@
const starterPlanId = 'starter'
export function isStarterPlan(planId?: string | null) {
return planId === starterPlanId
}
export function getBusinessMenuLabel(planId?: string | null, businessLimit?: number | null) {
return isStarterPlan(planId) || Number(businessLimit) === 1 ? 'Business' : 'Businesses'
}
export function getBusinessProfileNoun(count?: number | null) {
return Number(count) === 1 ? 'business profile' : 'business profiles'
}
export function getBusinessProfileLimitLabel(limit?: number | null) {
const numericLimit = Number(limit) || 0
return `${numericLimit.toLocaleString()} ${getBusinessProfileNoun(numericLimit)}`
}
export function getBusinessProfileUsageLabel(used?: number | null, limit?: number | null) {
const numericUsed = Number(used) || 0
const numericLimit = Number(limit) || 0
return `${numericUsed.toLocaleString()} / ${getBusinessProfileLimitLabel(numericLimit)}`
}

View File

@ -0,0 +1,15 @@
const internalAdminRoleNames = ['Administrator']
export function getRoleName(user?: any) {
return user?.app_role?.name || ''
}
export function isInternalAdmin(user?: any) {
const roleName = getRoleName(user)
return internalAdminRoleNames.includes(roleName) || user?.email === 'admin@flatlogic.com'
}
export function getPortalLabel(user?: any) {
return isInternalAdmin(user) ? 'Internal Admin Portal' : 'Customer Workspace'
}

View File

@ -1,18 +1,21 @@
export function hasPermission(user, permission_name: string | string[]) { export function hasPermission(user, permission_name?: string | string[]) {
if (!user?.app_role?.name) return false; if (!user?.app_role?.name) return false
if (!permission_name) { if (!permission_name) {
return true; return true
} }
if (user.app_role.name === 'Administrator') return true
const permissions = new Set<string>([ const permissions = new Set<string>([
...(user?.custom_permissions ?? []).map((p) => p.name), ...(user?.custom_permissions ?? []).map((p) => p.name),
...(user?.app_role_permissions ?? []).map((p) => p.name), ...(user?.app_role_permissions ?? []).map((p) => p.name),
]); ])
if (typeof permission_name === 'string') { if (typeof permission_name === 'string') {
return permissions.has(permission_name) || user.app_role.name === 'Administrator' return permissions.has(permission_name)
} else {
return permission_name.some((permission) => permissions.has(permission));
} }
return permission_name.some((permission) => permissions.has(permission))
} }

View File

@ -1,7 +1,7 @@
import React, { ReactNode, useEffect, useState } from 'react' import React, { ReactNode, useEffect, useMemo, useState } from 'react'
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside' import { getMenuAsideForUser } from '../menuAside'
import menuNavBar from '../menuNavBar' import menuNavBar from '../menuNavBar'
import BaseIcon from '../components/BaseIcon' import BaseIcon from '../components/BaseIcon'
import NavBar from '../components/NavBar' import NavBar from '../components/NavBar'
@ -14,19 +14,23 @@ import { useRouter } from 'next/router'
import {findMe, logoutUser} from "../stores/authSlice"; import {findMe, logoutUser} from "../stores/authSlice";
import {hasPermission} from "../helpers/userPermissions"; import {hasPermission} from "../helpers/userPermissions";
import { getBusinessMenuLabel } from '../helpers/businessPlanLabels';
import { getPortalLabel, isInternalAdmin } from '../helpers/portalRoles';
type Props = { type Props = {
children: ReactNode children: ReactNode
permission?: string permission?: string
portal?: 'admin' | 'customer'
} }
export default function LayoutAuthenticated({ export default function LayoutAuthenticated({
children, children,
permission permission,
portal
}: Props) { }: Props) {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
@ -62,12 +66,39 @@ export default function LayoutAuthenticated({
if (!hasPermission(currentUser, permission)) router.push('/error'); if (!hasPermission(currentUser, permission)) router.push('/error');
}, [currentUser, permission]); }, [currentUser, permission]);
useEffect(() => {
if (!portal || !currentUser) return;
const isAdminPortal = isInternalAdmin(currentUser);
if ((portal === 'admin' && !isAdminPortal) || (portal === 'customer' && isAdminPortal)) {
router.push('/dashboard');
}
}, [currentUser, portal, router]);
const darkMode = useAppSelector((state) => state.style.darkMode) const darkMode = useAppSelector((state) => state.style.darkMode)
const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false) const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false)
const [isAsideLgActive, setIsAsideLgActive] = useState(false) const [isAsideLgActive, setIsAsideLgActive] = useState(false)
const businessMenuLabel = isInternalAdmin(currentUser)
? 'Business profiles'
: getBusinessMenuLabel(currentUser?.subscriptionPlanId)
const portalLabel = getPortalLabel(currentUser)
const planAwareMenuAside = useMemo(() => getMenuAsideForUser(currentUser).map((item) => {
const children = item.menu?.map((child) => (
child.href === '/businesses/businesses-list'
? { ...child, label: businessMenuLabel }
: child
))
if (item.href === '/businesses/businesses-list') {
return { ...item, label: businessMenuLabel }
}
return children ? { ...item, menu: children } : item
}), [businessMenuLabel, currentUser])
useEffect(() => { useEffect(() => {
const handleRouteChangeStart = () => { const handleRouteChangeStart = () => {
@ -117,11 +148,11 @@ export default function LayoutAuthenticated({
<AsideMenu <AsideMenu
isAsideMobileExpanded={isAsideMobileExpanded} isAsideMobileExpanded={isAsideMobileExpanded}
isAsideLgActive={isAsideLgActive} isAsideLgActive={isAsideLgActive}
menu={menuAside} menu={planAwareMenuAside}
onAsideLgClose={() => setIsAsideLgActive(false)} onAsideLgClose={() => setIsAsideLgActive(false)}
/> />
{children} {children}
<FooterBar>Hand-crafted & Made with </FooterBar> <FooterBar>{portalLabel} · ReviewFlow</FooterBar>
</div> </div>
</div> </div>
) )

View File

@ -1,115 +1,193 @@
import * as icon from '@mdi/js'; import * as icon from '@mdi/js'
import { MenuAsideItem } from './interfaces'; import { MenuAsideItem } from './interfaces'
import { isInternalAdmin } from './helpers/portalRoles'
const menuAside: MenuAsideItem[] = [ const storeIcon =
'mdiStore' in icon
? icon['mdiStore' as keyof typeof icon]
: icon.mdiTable
const accountMultipleIcon =
'mdiAccountMultiple' in icon
? icon['mdiAccountMultiple' as keyof typeof icon]
: icon.mdiTable
const emailFastIcon =
'mdiEmailFastOutline' in icon
? icon['mdiEmailFastOutline' as keyof typeof icon]
: icon.mdiTable
const webhookIcon =
'mdiWebhook' in icon
? icon['mdiWebhook' as keyof typeof icon]
: icon.mdiTable
const emailCheckIcon =
'mdiEmailCheckOutline' in icon
? icon['mdiEmailCheckOutline' as keyof typeof icon]
: icon.mdiTable
const clockIcon =
'mdiClockOutline' in icon
? icon['mdiClockOutline' as keyof typeof icon]
: icon.mdiTable
export const customerMenuAside: MenuAsideItem[] = [
{ {
href: '/dashboard', href: '/dashboard',
icon: icon.mdiViewDashboardOutline, icon: icon.mdiViewDashboardOutline,
label: 'Dashboard', label: 'Workspace dashboard',
}, },
{ {
href: '/reviewflow', href: '/reviewflow',
icon: icon.mdiStarOutline, icon: icon.mdiStarOutline,
label: 'Review Flow', label: 'Review Flow',
}, },
{
href: '/subscription',
icon: icon.mdiCreditCardOutline,
label: 'Subscription',
},
{
href: '/users/users-list',
label: 'Users',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiAccountGroup ?? icon.mdiTable,
permissions: 'READ_USERS',
},
{ {
href: '/businesses/businesses-list', href: '/businesses/businesses-list',
label: 'Businesses', label: 'Businesses',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment icon: storeIcon,
// @ts-ignore
icon:
'mdiStore' in icon
? icon['mdiStore' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_BUSINESSES', permissions: 'READ_BUSINESSES',
}, },
{ {
href: '/customers/customers-list', href: '/customers/customers-list',
label: 'Customers', label: 'Customers',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment icon: accountMultipleIcon,
// @ts-ignore
icon:
'mdiAccountMultiple' in icon
? icon['mdiAccountMultiple' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_CUSTOMERS', permissions: 'READ_CUSTOMERS',
}, },
{ {
href: '/transactions/transactions-list', href: '/transactions/transactions-list',
label: 'Transactions', label: 'Transactions',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment icon: icon.mdiCreditCardOutline,
// @ts-ignore
icon:
'mdiCreditCardOutline' in icon
? icon['mdiCreditCardOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_TRANSACTIONS', permissions: 'READ_TRANSACTIONS',
}, },
{ {
href: '/review_requests/review_requests-list', href: '/review_requests/review_requests-list',
label: 'Review requests', label: 'Review requests',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment icon: emailFastIcon,
// @ts-ignore
icon:
'mdiEmailFastOutline' in icon
? icon['mdiEmailFastOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_REVIEW_REQUESTS', permissions: 'READ_REVIEW_REQUESTS',
}, },
{
href: '/stripe_events/stripe_events-list',
label: 'Payment events',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon:
'mdiWebhook' in icon
? icon['mdiWebhook' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_STRIPE_EVENTS',
},
{ {
href: '/email_delivery_logs/email_delivery_logs-list', href: '/email_delivery_logs/email_delivery_logs-list',
label: 'Email delivery logs', label: 'Email delivery',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment icon: emailCheckIcon,
// @ts-ignore
icon:
'mdiEmailCheckOutline' in icon
? icon['mdiEmailCheckOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_EMAIL_DELIVERY_LOGS', permissions: 'READ_EMAIL_DELIVERY_LOGS',
}, },
{ {
href: '/cron_runs/cron_runs-list', href: '/subscription',
label: 'Cron runs', icon: icon.mdiCreditCardOutline,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment label: 'Subscription',
// @ts-ignore
icon:
'mdiClockOutline' in icon
? icon['mdiClockOutline' as keyof typeof icon]
: (icon.mdiTable ?? icon.mdiTable),
permissions: 'READ_CRON_RUNS',
}, },
{ {
href: '/profile', href: '/profile',
label: 'Profile', label: 'Profile',
icon: icon.mdiAccountCircle, icon: icon.mdiAccountCircle,
}, },
]; ]
export default menuAside; export const internalAdminMenuAside: MenuAsideItem[] = [
{
href: '/dashboard',
icon: icon.mdiViewDashboardOutline,
label: 'Admin dashboard',
},
{
label: 'Customer operations',
icon: icon.mdiAccountGroup,
permissions: ['READ_USERS', 'READ_BUSINESSES', 'READ_CUSTOMERS'],
menu: [
{
href: '/users/users-list',
label: 'Customer accounts',
icon: icon.mdiAccountGroup,
permissions: 'READ_USERS',
},
{
href: '/businesses/businesses-list',
label: 'Business profiles',
icon: storeIcon,
permissions: 'READ_BUSINESSES',
},
{
href: '/customers/customers-list',
label: 'End customers',
icon: accountMultipleIcon,
permissions: 'READ_CUSTOMERS',
},
],
},
{
label: 'Review operations',
icon: icon.mdiStarOutline,
permissions: ['READ_REVIEW_REQUESTS', 'READ_EMAIL_DELIVERY_LOGS', 'READ_CRON_RUNS'],
menu: [
{
href: '/review_requests/review_requests-list',
label: 'Review requests',
icon: emailFastIcon,
permissions: 'READ_REVIEW_REQUESTS',
},
{
href: '/email_delivery_logs/email_delivery_logs-list',
label: 'Email delivery logs',
icon: emailCheckIcon,
permissions: 'READ_EMAIL_DELIVERY_LOGS',
},
{
href: '/cron_runs/cron_runs-list',
label: 'Automation runs',
icon: clockIcon,
permissions: 'READ_CRON_RUNS',
},
],
},
{
label: 'Billing & payments',
icon: icon.mdiCreditCardOutline,
permissions: ['READ_TRANSACTIONS', 'READ_STRIPE_EVENTS'],
menu: [
{
href: '/transactions/transactions-list',
label: 'Transactions',
icon: icon.mdiCreditCardOutline,
permissions: 'READ_TRANSACTIONS',
},
{
href: '/stripe_events/stripe_events-list',
label: 'Payment events',
icon: webhookIcon,
permissions: 'READ_STRIPE_EVENTS',
},
],
},
{
label: 'Access control',
icon: icon.mdiShieldAccountVariantOutline,
permissions: ['READ_ROLES', 'READ_PERMISSIONS'],
menu: [
{
href: '/roles/roles-list',
label: 'Roles',
icon: icon.mdiShieldAccountVariantOutline,
permissions: 'READ_ROLES',
},
{
href: '/permissions/permissions-list',
label: 'Permissions',
icon: icon.mdiKeyVariant,
permissions: 'READ_PERMISSIONS',
},
],
},
{
href: '/profile',
label: 'Profile',
icon: icon.mdiAccountCircle,
},
]
export function getMenuAsideForUser(user?: any) {
return isInternalAdmin(user) ? internalAdminMenuAside : customerMenuAside
}
export default customerMenuAside

View File

@ -469,10 +469,10 @@ const EditBusinesses = () => {
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('Edit businesses')}</title> <title>{getPageTitle('Edit Business')}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit businesses'} main> <SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='Edit Business' main>
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox> <CardBox>
@ -550,11 +550,11 @@ const EditBusinesses = () => {
<FormField <FormField
label="BusinessName" label="Business name"
> >
<Field <Field
name="name" name="name"
placeholder="BusinessName" placeholder="Business name"
/> />
</FormField> </FormField>
@ -587,11 +587,11 @@ const EditBusinesses = () => {
<FormField <FormField
label="GoogleReviewLink" label="Google review link"
> >
<Field <Field
name="google_review_link" name="google_review_link"
placeholder="GoogleReviewLink" placeholder="Google review link"
/> />
</FormField> </FormField>
@ -624,11 +624,11 @@ const EditBusinesses = () => {
<FormField <FormField
label="YelpReviewLink" label="Yelp review link"
> >
<Field <Field
name="yelp_review_link" name="yelp_review_link"
placeholder="YelpReviewLink" placeholder="Yelp review link"
/> />
</FormField> </FormField>
@ -661,11 +661,11 @@ const EditBusinesses = () => {
<FormField <FormField
label="FacebookReviewLink" label="Facebook review link"
> >
<Field <Field
name="facebook_review_link" name="facebook_review_link"
placeholder="FacebookReviewLink" placeholder="Facebook review link"
/> />
</FormField> </FormField>
@ -704,12 +704,12 @@ const EditBusinesses = () => {
<FormField <FormField
label="DelayDays" label="Review delay days"
> >
<Field <Field
type="number" type="number"
name="delay_days" name="delay_days"
placeholder="DelayDays" placeholder="Review delay days"
/> />
</FormField> </FormField>
@ -736,11 +736,11 @@ const EditBusinesses = () => {
<FormField <FormField
label="EmailSubjectTemplate" label="Email subject template"
> >
<Field <Field
name="email_subject_template" name="email_subject_template"
placeholder="EmailSubjectTemplate" placeholder="Email subject template"
/> />
</FormField> </FormField>
@ -776,7 +776,7 @@ const EditBusinesses = () => {
<FormField label='EmailBodyTemplate' hasTextareaHeight> <FormField label='Email body template' hasTextareaHeight>
<Field <Field
name='email_body_template' name='email_body_template'
id='email_body_template' id='email_body_template'
@ -824,7 +824,7 @@ const EditBusinesses = () => {
<FormField label='IsActive' labelFor='is_active'> <FormField label='Active' labelFor='is_active'>
<Field <Field
name='is_active' name='is_active'
id='is_active' id='is_active'
@ -845,11 +845,11 @@ const EditBusinesses = () => {
<FormField <FormField
label="StripeAccountReference" label="Stripe account reference"
> >
<Field <Field
name="stripe_account_reference" name="stripe_account_reference"
placeholder="StripeAccountReference" placeholder="Stripe account reference"
/> />
</FormField> </FormField>
@ -897,7 +897,7 @@ const EditBusinesses = () => {
<FormField label='StripeConnected' labelFor='stripe_connected'> <FormField label='Stripe connected' labelFor='stripe_connected'>
<Field <Field
name='stripe_connected' name='stripe_connected'
id='stripe_connected' id='stripe_connected'
@ -928,7 +928,7 @@ const EditBusinesses = () => {
<FormField <FormField
label="StripeConnectedAt" label="Stripe connected at"
> >
<DatePicker <DatePicker
dateFormat="yyyy-MM-dd hh:mm" dateFormat="yyyy-MM-dd hh:mm"
@ -974,7 +974,7 @@ const EditBusinesses = () => {
<FormField label="DefaultReviewPlatform" labelFor="default_review_platform"> <FormField label="Default review platform" labelFor="default_review_platform">
<Field name="default_review_platform" id="default_review_platform" component="select"> <Field name="default_review_platform" id="default_review_platform" component="select">
<option value="google">google</option> <option value="google">google</option>
@ -1003,11 +1003,11 @@ const EditBusinesses = () => {
<FormField <FormField
label="CustomReviewLink" label="Custom review link"
> >
<Field <Field
name="custom_review_link" name="custom_review_link"
placeholder="CustomReviewLink" placeholder="Custom review link"
/> />
</FormField> </FormField>

View File

@ -466,10 +466,10 @@ const EditBusinessesPage = () => {
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('Edit businesses')}</title> <title>{getPageTitle('Edit Business')}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit businesses'} main> <SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='Edit Business' main>
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox> <CardBox>
@ -547,11 +547,11 @@ const EditBusinessesPage = () => {
<FormField <FormField
label="BusinessName" label="Business name"
> >
<Field <Field
name="name" name="name"
placeholder="BusinessName" placeholder="Business name"
/> />
</FormField> </FormField>
@ -584,11 +584,11 @@ const EditBusinessesPage = () => {
<FormField <FormField
label="GoogleReviewLink" label="Google review link"
> >
<Field <Field
name="google_review_link" name="google_review_link"
placeholder="GoogleReviewLink" placeholder="Google review link"
/> />
</FormField> </FormField>
@ -621,11 +621,11 @@ const EditBusinessesPage = () => {
<FormField <FormField
label="YelpReviewLink" label="Yelp review link"
> >
<Field <Field
name="yelp_review_link" name="yelp_review_link"
placeholder="YelpReviewLink" placeholder="Yelp review link"
/> />
</FormField> </FormField>
@ -658,11 +658,11 @@ const EditBusinessesPage = () => {
<FormField <FormField
label="FacebookReviewLink" label="Facebook review link"
> >
<Field <Field
name="facebook_review_link" name="facebook_review_link"
placeholder="FacebookReviewLink" placeholder="Facebook review link"
/> />
</FormField> </FormField>
@ -701,12 +701,12 @@ const EditBusinessesPage = () => {
<FormField <FormField
label="DelayDays" label="Review delay days"
> >
<Field <Field
type="number" type="number"
name="delay_days" name="delay_days"
placeholder="DelayDays" placeholder="Review delay days"
/> />
</FormField> </FormField>
@ -733,11 +733,11 @@ const EditBusinessesPage = () => {
<FormField <FormField
label="EmailSubjectTemplate" label="Email subject template"
> >
<Field <Field
name="email_subject_template" name="email_subject_template"
placeholder="EmailSubjectTemplate" placeholder="Email subject template"
/> />
</FormField> </FormField>
@ -773,7 +773,7 @@ const EditBusinessesPage = () => {
<FormField label='EmailBodyTemplate' hasTextareaHeight> <FormField label='Email body template' hasTextareaHeight>
<Field <Field
name='email_body_template' name='email_body_template'
id='email_body_template' id='email_body_template'
@ -821,7 +821,7 @@ const EditBusinessesPage = () => {
<FormField label='IsActive' labelFor='is_active'> <FormField label='Active' labelFor='is_active'>
<Field <Field
name='is_active' name='is_active'
id='is_active' id='is_active'
@ -842,11 +842,11 @@ const EditBusinessesPage = () => {
<FormField <FormField
label="StripeAccountReference" label="Stripe account reference"
> >
<Field <Field
name="stripe_account_reference" name="stripe_account_reference"
placeholder="StripeAccountReference" placeholder="Stripe account reference"
/> />
</FormField> </FormField>
@ -894,7 +894,7 @@ const EditBusinessesPage = () => {
<FormField label='StripeConnected' labelFor='stripe_connected'> <FormField label='Stripe connected' labelFor='stripe_connected'>
<Field <Field
name='stripe_connected' name='stripe_connected'
id='stripe_connected' id='stripe_connected'
@ -925,7 +925,7 @@ const EditBusinessesPage = () => {
<FormField <FormField
label="StripeConnectedAt" label="Stripe connected at"
> >
<DatePicker <DatePicker
dateFormat="yyyy-MM-dd hh:mm" dateFormat="yyyy-MM-dd hh:mm"
@ -971,7 +971,7 @@ const EditBusinessesPage = () => {
<FormField label="DefaultReviewPlatform" labelFor="default_review_platform"> <FormField label="Default review platform" labelFor="default_review_platform">
<Field name="default_review_platform" id="default_review_platform" component="select"> <Field name="default_review_platform" id="default_review_platform" component="select">
<option value="google">google</option> <option value="google">google</option>
@ -1000,11 +1000,11 @@ const EditBusinessesPage = () => {
<FormField <FormField
label="CustomReviewLink" label="Custom review link"
> >
<Field <Field
name="custom_review_link" name="custom_review_link"
placeholder="CustomReviewLink" placeholder="Custom review link"
/> />
</FormField> </FormField>

View File

@ -14,10 +14,13 @@ import Link from "next/link";
import {useAppDispatch, useAppSelector} from "../../stores/hooks"; import {useAppDispatch, useAppSelector} from "../../stores/hooks";
import CardBoxModal from "../../components/CardBoxModal"; import CardBoxModal from "../../components/CardBoxModal";
import DragDropFilePicker from "../../components/DragDropFilePicker"; import DragDropFilePicker from "../../components/DragDropFilePicker";
import SubscriptionLimitGate from '../../components/SubscriptionLimitGate';
import {setRefetch, uploadCsv} from '../../stores/businesses/businessesSlice'; import {setRefetch, uploadCsv} from '../../stores/businesses/businessesSlice';
import {hasPermission} from "../../helpers/userPermissions"; import {hasPermission} from "../../helpers/userPermissions";
import { getBusinessMenuLabel } from '../../helpers/businessPlanLabels';
import { isInternalAdmin } from '../../helpers/portalRoles';
@ -34,20 +37,22 @@ const BusinessesTablesPage = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [filters] = useState([{label: 'BusinessName', title: 'name'},{label: 'GoogleReviewLink', title: 'google_review_link'},{label: 'YelpReviewLink', title: 'yelp_review_link'},{label: 'FacebookReviewLink', title: 'facebook_review_link'},{label: 'EmailSubjectTemplate', title: 'email_subject_template'},{label: 'EmailBodyTemplate', title: 'email_body_template'},{label: 'StripeAccountReference', title: 'stripe_account_reference'},{label: 'CustomReviewLink', title: 'custom_review_link'}, const [filters] = useState([{label: 'Business name', title: 'name'},{label: 'Google review link', title: 'google_review_link'},{label: 'Yelp review link', title: 'yelp_review_link'},{label: 'Facebook review link', title: 'facebook_review_link'},{label: 'Email subject template', title: 'email_subject_template'},{label: 'Email body template', title: 'email_body_template'},{label: 'Stripe account reference', title: 'stripe_account_reference'},{label: 'Custom review link', title: 'custom_review_link'},
{label: 'DelayDays', title: 'delay_days', number: 'true'}, {label: 'Review delay days', title: 'delay_days', number: 'true'},
{label: 'StripeConnectedAt', title: 'stripe_connected_at', date: 'true'}, {label: 'Stripe connected at', title: 'stripe_connected_at', date: 'true'},
{label: 'Owner', title: 'owner'}, {label: 'Owner', title: 'owner'},
{label: 'DefaultReviewPlatform', title: 'default_review_platform', type: 'enum', options: ['google','yelp','facebook','custom']}, {label: 'Default review platform', title: 'default_review_platform', type: 'enum', options: ['google','yelp','facebook','custom']},
]); ]);
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_BUSINESSES'); const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_BUSINESSES');
const isAdminPortal = isInternalAdmin(currentUser);
const businessPageTitle = isAdminPortal ? 'Business profiles' : getBusinessMenuLabel(currentUser?.subscriptionPlanId);
const addFilter = () => { const addFilter = () => {
@ -90,15 +95,27 @@ const BusinessesTablesPage = () => {
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('Businesses')}</title> <title>{getPageTitle(businessPageTitle)}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Businesses" main> <SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={businessPageTitle} main>
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'> <CardBox className='mb-6 border-0 bg-indigo-50 text-indigo-950 ring-1 ring-indigo-100 dark:bg-indigo-950 dark:text-indigo-50 dark:ring-indigo-900'>
<p className='font-black'>{businessPageTitle} setup</p>
<p className='mt-2 text-sm leading-6'>
{isAdminPortal
? 'Manage customer business profiles used for review links, email templates, delay timing, and payment/webhook settings. Internal admin management is not limited by customer subscription plans.'
: 'Stores the business profile used for review links, email templates, delay timing, and payment/webhook settings. Starter accounts manage one business profile; Pro accounts can manage up to ten.'}
</p>
</CardBox>
<SubscriptionLimitGate
limitKey='businesses'
actionLabel='Adding another business profile'
/>
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/businesses/businesses-new'} color='info' label='New Item'/>} {hasCreatePermission && <BaseButton className={'mr-3'} href={'/businesses/businesses-new'} color='info' label='Add Business'/>}
<BaseButton <BaseButton
className={'mr-3'} className={'mr-3'}

View File

@ -272,15 +272,15 @@ const BusinessesNew = () => {
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('New Item')}</title> <title>{getPageTitle('Add Business')}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main> <SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='Add Business' main>
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<SubscriptionLimitGate <SubscriptionLimitGate
limitKey='businesses' limitKey='businesses'
actionLabel='Adding another business/location' actionLabel='Adding another business profile'
/> />
<CardBox> <CardBox>
<Formik <Formik
@ -326,11 +326,11 @@ const BusinessesNew = () => {
<FormField <FormField
label="BusinessName" label="Business name"
> >
<Field <Field
name="name" name="name"
placeholder="BusinessName" placeholder="Business name"
/> />
</FormField> </FormField>
@ -361,11 +361,11 @@ const BusinessesNew = () => {
<FormField <FormField
label="GoogleReviewLink" label="Google review link"
> >
<Field <Field
name="google_review_link" name="google_review_link"
placeholder="GoogleReviewLink" placeholder="Google review link"
/> />
</FormField> </FormField>
@ -396,11 +396,11 @@ const BusinessesNew = () => {
<FormField <FormField
label="YelpReviewLink" label="Yelp review link"
> >
<Field <Field
name="yelp_review_link" name="yelp_review_link"
placeholder="YelpReviewLink" placeholder="Yelp review link"
/> />
</FormField> </FormField>
@ -431,11 +431,11 @@ const BusinessesNew = () => {
<FormField <FormField
label="FacebookReviewLink" label="Facebook review link"
> >
<Field <Field
name="facebook_review_link" name="facebook_review_link"
placeholder="FacebookReviewLink" placeholder="Facebook review link"
/> />
</FormField> </FormField>
@ -472,12 +472,12 @@ const BusinessesNew = () => {
<FormField <FormField
label="DelayDays" label="Review delay days"
> >
<Field <Field
type="number" type="number"
name="delay_days" name="delay_days"
placeholder="DelayDays" placeholder="Review delay days"
/> />
</FormField> </FormField>
@ -502,11 +502,11 @@ const BusinessesNew = () => {
<FormField <FormField
label="EmailSubjectTemplate" label="Email subject template"
> >
<Field <Field
name="email_subject_template" name="email_subject_template"
placeholder="EmailSubjectTemplate" placeholder="Email subject template"
/> />
</FormField> </FormField>
@ -540,7 +540,7 @@ const BusinessesNew = () => {
<FormField label='EmailBodyTemplate' hasTextareaHeight> <FormField label='Email body template' hasTextareaHeight>
<Field <Field
name='email_body_template' name='email_body_template'
id='email_body_template' id='email_body_template'
@ -586,7 +586,7 @@ const BusinessesNew = () => {
<FormField label='IsActive' labelFor='is_active'> <FormField label='Active' labelFor='is_active'>
<Field <Field
name='is_active' name='is_active'
id='is_active' id='is_active'
@ -605,11 +605,11 @@ const BusinessesNew = () => {
<FormField <FormField
label="StripeAccountReference" label="Stripe account reference"
> >
<Field <Field
name="stripe_account_reference" name="stripe_account_reference"
placeholder="StripeAccountReference" placeholder="Stripe account reference"
/> />
</FormField> </FormField>
@ -655,7 +655,7 @@ const BusinessesNew = () => {
<FormField label='StripeConnected' labelFor='stripe_connected'> <FormField label='Stripe connected' labelFor='stripe_connected'>
<Field <Field
name='stripe_connected' name='stripe_connected'
id='stripe_connected' id='stripe_connected'
@ -684,12 +684,12 @@ const BusinessesNew = () => {
<FormField <FormField
label="StripeConnectedAt" label="Stripe connected at"
> >
<Field <Field
type="datetime-local" type="datetime-local"
name="stripe_connected_at" name="stripe_connected_at"
placeholder="StripeConnectedAt" placeholder="Stripe connected at"
/> />
</FormField> </FormField>
@ -723,7 +723,7 @@ const BusinessesNew = () => {
<FormField label="DefaultReviewPlatform" labelFor="default_review_platform"> <FormField label="Default review platform" labelFor="default_review_platform">
<Field name="default_review_platform" id="default_review_platform" component="select"> <Field name="default_review_platform" id="default_review_platform" component="select">
<option value="google">google</option> <option value="google">google</option>
@ -750,11 +750,11 @@ const BusinessesNew = () => {
<FormField <FormField
label="CustomReviewLink" label="Custom review link"
> >
<Field <Field
name="custom_review_link" name="custom_review_link"
placeholder="CustomReviewLink" placeholder="Custom review link"
/> />
</FormField> </FormField>

View File

@ -14,10 +14,13 @@ import Link from "next/link";
import {useAppDispatch, useAppSelector} from "../../stores/hooks"; import {useAppDispatch, useAppSelector} from "../../stores/hooks";
import CardBoxModal from "../../components/CardBoxModal"; import CardBoxModal from "../../components/CardBoxModal";
import DragDropFilePicker from "../../components/DragDropFilePicker"; import DragDropFilePicker from "../../components/DragDropFilePicker";
import SubscriptionLimitGate from '../../components/SubscriptionLimitGate';
import {setRefetch, uploadCsv} from '../../stores/businesses/businessesSlice'; import {setRefetch, uploadCsv} from '../../stores/businesses/businessesSlice';
import {hasPermission} from "../../helpers/userPermissions"; import {hasPermission} from "../../helpers/userPermissions";
import { getBusinessMenuLabel } from '../../helpers/businessPlanLabels';
import { isInternalAdmin } from '../../helpers/portalRoles';
@ -34,20 +37,22 @@ const BusinessesTablesPage = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [filters] = useState([{label: 'BusinessName', title: 'name'},{label: 'GoogleReviewLink', title: 'google_review_link'},{label: 'YelpReviewLink', title: 'yelp_review_link'},{label: 'FacebookReviewLink', title: 'facebook_review_link'},{label: 'EmailSubjectTemplate', title: 'email_subject_template'},{label: 'EmailBodyTemplate', title: 'email_body_template'},{label: 'StripeAccountReference', title: 'stripe_account_reference'},{label: 'CustomReviewLink', title: 'custom_review_link'}, const [filters] = useState([{label: 'Business name', title: 'name'},{label: 'Google review link', title: 'google_review_link'},{label: 'Yelp review link', title: 'yelp_review_link'},{label: 'Facebook review link', title: 'facebook_review_link'},{label: 'Email subject template', title: 'email_subject_template'},{label: 'Email body template', title: 'email_body_template'},{label: 'Stripe account reference', title: 'stripe_account_reference'},{label: 'Custom review link', title: 'custom_review_link'},
{label: 'DelayDays', title: 'delay_days', number: 'true'}, {label: 'Review delay days', title: 'delay_days', number: 'true'},
{label: 'StripeConnectedAt', title: 'stripe_connected_at', date: 'true'}, {label: 'Stripe connected at', title: 'stripe_connected_at', date: 'true'},
{label: 'Owner', title: 'owner'}, {label: 'Owner', title: 'owner'},
{label: 'DefaultReviewPlatform', title: 'default_review_platform', type: 'enum', options: ['google','yelp','facebook','custom']}, {label: 'Default review platform', title: 'default_review_platform', type: 'enum', options: ['google','yelp','facebook','custom']},
]); ]);
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_BUSINESSES'); const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_BUSINESSES');
const isAdminPortal = isInternalAdmin(currentUser);
const businessPageTitle = isAdminPortal ? 'Business profiles' : getBusinessMenuLabel(currentUser?.subscriptionPlanId);
const addFilter = () => { const addFilter = () => {
@ -90,15 +95,27 @@ const BusinessesTablesPage = () => {
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('Businesses')}</title> <title>{getPageTitle(businessPageTitle)}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Businesses" main> <SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={businessPageTitle} main>
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox className='mb-6 border-0 bg-indigo-50 text-indigo-950 ring-1 ring-indigo-100 dark:bg-indigo-950 dark:text-indigo-50 dark:ring-indigo-900'>
<p className='font-black'>{businessPageTitle} setup</p>
<p className='mt-2 text-sm leading-6'>
{isAdminPortal
? 'Manage customer business profiles used for review links, email templates, delay timing, and payment/webhook settings. Internal admin management is not limited by customer subscription plans.'
: 'Stores the business profile used for review links, email templates, delay timing, and payment/webhook settings. Starter accounts manage one business profile; Pro accounts can manage up to ten.'}
</p>
</CardBox>
<SubscriptionLimitGate
limitKey='businesses'
actionLabel='Adding another business profile'
/>
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'> <CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/businesses/businesses-new'} color='info' label='New Item'/>} {hasCreatePermission && <BaseButton className={'mr-3'} href={'/businesses/businesses-new'} color='info' label='Add Business'/>}
<BaseButton <BaseButton
className={'mr-3'} className={'mr-3'}

View File

@ -29,11 +29,6 @@ const BusinessesView = () => {
const { id } = router.query; const { id } = router.query;
function removeLastCharacter(str) {
console.log(str,`str`)
return str.slice(0, -1);
}
useEffect(() => { useEffect(() => {
dispatch(fetch({ id })); dispatch(fetch({ id }));
}, [dispatch, id]); }, [dispatch, id]);
@ -42,10 +37,10 @@ const BusinessesView = () => {
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('View businesses')}</title> <title>{getPageTitle('View Business')}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={removeLastCharacter('View businesses')} main> <SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='View Business' main>
<BaseButton <BaseButton
color='info' color='info'
label='Edit' label='Edit'
@ -113,7 +108,7 @@ const BusinessesView = () => {
<div className={'mb-4'}> <div className={'mb-4'}>
<p className={'block font-bold mb-2'}>BusinessName</p> <p className={'block font-bold mb-2'}>Business name</p>
<p>{businesses?.name}</p> <p>{businesses?.name}</p>
</div> </div>
@ -145,7 +140,7 @@ const BusinessesView = () => {
<div className={'mb-4'}> <div className={'mb-4'}>
<p className={'block font-bold mb-2'}>GoogleReviewLink</p> <p className={'block font-bold mb-2'}>Google review link</p>
<p>{businesses?.google_review_link}</p> <p>{businesses?.google_review_link}</p>
</div> </div>
@ -177,7 +172,7 @@ const BusinessesView = () => {
<div className={'mb-4'}> <div className={'mb-4'}>
<p className={'block font-bold mb-2'}>YelpReviewLink</p> <p className={'block font-bold mb-2'}>Yelp review link</p>
<p>{businesses?.yelp_review_link}</p> <p>{businesses?.yelp_review_link}</p>
</div> </div>
@ -209,7 +204,7 @@ const BusinessesView = () => {
<div className={'mb-4'}> <div className={'mb-4'}>
<p className={'block font-bold mb-2'}>FacebookReviewLink</p> <p className={'block font-bold mb-2'}>Facebook review link</p>
<p>{businesses?.facebook_review_link}</p> <p>{businesses?.facebook_review_link}</p>
</div> </div>
@ -247,7 +242,7 @@ const BusinessesView = () => {
<div className={'mb-4'}> <div className={'mb-4'}>
<p className={'block font-bold mb-2'}>DelayDays</p> <p className={'block font-bold mb-2'}>Review delay days</p>
<p>{businesses?.delay_days || 'No data'}</p> <p>{businesses?.delay_days || 'No data'}</p>
</div> </div>
@ -273,7 +268,7 @@ const BusinessesView = () => {
<div className={'mb-4'}> <div className={'mb-4'}>
<p className={'block font-bold mb-2'}>EmailSubjectTemplate</p> <p className={'block font-bold mb-2'}>Email subject template</p>
<p>{businesses?.email_subject_template}</p> <p>{businesses?.email_subject_template}</p>
</div> </div>
@ -309,7 +304,7 @@ const BusinessesView = () => {
<div className={'mb-4'}> <div className={'mb-4'}>
<p className={'block font-bold mb-2'}>EmailBodyTemplate</p> <p className={'block font-bold mb-2'}>Email body template</p>
{businesses.email_body_template {businesses.email_body_template
? <p dangerouslySetInnerHTML={{__html: businesses.email_body_template}}/> ? <p dangerouslySetInnerHTML={{__html: businesses.email_body_template}}/>
: <p>No data</p> : <p>No data</p>
@ -355,7 +350,7 @@ const BusinessesView = () => {
<FormField label='IsActive'> <FormField label='Active'>
<SwitchField <SwitchField
field={{name: 'is_active', value: businesses?.is_active}} field={{name: 'is_active', value: businesses?.is_active}}
form={{setFieldValue: () => null}} form={{setFieldValue: () => null}}
@ -375,7 +370,7 @@ const BusinessesView = () => {
<div className={'mb-4'}> <div className={'mb-4'}>
<p className={'block font-bold mb-2'}>StripeAccountReference</p> <p className={'block font-bold mb-2'}>Stripe account reference</p>
<p>{businesses?.stripe_account_reference}</p> <p>{businesses?.stripe_account_reference}</p>
</div> </div>
@ -422,7 +417,7 @@ const BusinessesView = () => {
<FormField label='StripeConnected'> <FormField label='Stripe connected'>
<SwitchField <SwitchField
field={{name: 'stripe_connected', value: businesses?.stripe_connected}} field={{name: 'stripe_connected', value: businesses?.stripe_connected}}
form={{setFieldValue: () => null}} form={{setFieldValue: () => null}}
@ -451,7 +446,7 @@ const BusinessesView = () => {
<FormField label='StripeConnectedAt'> <FormField label='Stripe connected at'>
{businesses.stripe_connected_at ? <DatePicker {businesses.stripe_connected_at ? <DatePicker
dateFormat="yyyy-MM-dd hh:mm" dateFormat="yyyy-MM-dd hh:mm"
showTimeSelect showTimeSelect
@ -461,7 +456,7 @@ const BusinessesView = () => {
) : null ) : null
} }
disabled disabled
/> : <p>No StripeConnectedAt</p>} /> : <p>No Stripe connection date</p>}
</FormField> </FormField>
@ -496,7 +491,7 @@ const BusinessesView = () => {
<div className={'mb-4'}> <div className={'mb-4'}>
<p className={'block font-bold mb-2'}>DefaultReviewPlatform</p> <p className={'block font-bold mb-2'}>Default review platform</p>
<p>{businesses?.default_review_platform ?? 'No data'}</p> <p>{businesses?.default_review_platform ?? 'No data'}</p>
</div> </div>
@ -514,7 +509,7 @@ const BusinessesView = () => {
<div className={'mb-4'}> <div className={'mb-4'}>
<p className={'block font-bold mb-2'}>CustomReviewLink</p> <p className={'block font-bold mb-2'}>Custom review link</p>
<p>{businesses?.custom_review_link}</p> <p>{businesses?.custom_review_link}</p>
</div> </div>
@ -550,7 +545,7 @@ const BusinessesView = () => {
<> <>
<p className={'block font-bold mb-2'}>Customers Business</p> <p className={'block font-bold mb-2'}>Customers for this business</p>
<CardBox <CardBox
className='mb-6 border border-gray-300 rounded overflow-hidden' className='mb-6 border border-gray-300 rounded overflow-hidden'
hasTable hasTable

View File

@ -60,5 +60,5 @@ export default function ConnectPage() {
} }
ConnectPage.getLayout = function getLayout(page: ReactElement) { ConnectPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>; return <LayoutAuthenticated portal='customer'>{page}</LayoutAuthenticated>;
}; };

View File

@ -645,6 +645,7 @@ const EditCron_runs = () => {
EditCron_runs.getLayout = function getLayout(page: ReactElement) { EditCron_runs.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
portal='admin'
permission={'UPDATE_CRON_RUNS'} permission={'UPDATE_CRON_RUNS'}

View File

@ -642,6 +642,7 @@ const EditCron_runsPage = () => {
EditCron_runsPage.getLayout = function getLayout(page: ReactElement) { EditCron_runsPage.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
portal='admin'
permission={'UPDATE_CRON_RUNS'} permission={'UPDATE_CRON_RUNS'}

View File

@ -154,6 +154,7 @@ const Cron_runsTablesPage = () => {
Cron_runsTablesPage.getLayout = function getLayout(page: ReactElement) { Cron_runsTablesPage.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
portal='admin'
permission={'READ_CRON_RUNS'} permission={'READ_CRON_RUNS'}

View File

@ -504,6 +504,7 @@ const Cron_runsNew = () => {
Cron_runsNew.getLayout = function getLayout(page: ReactElement) { Cron_runsNew.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
portal='admin'
permission={'CREATE_CRON_RUNS'} permission={'CREATE_CRON_RUNS'}

View File

@ -152,6 +152,7 @@ const Cron_runsTablesPage = () => {
Cron_runsTablesPage.getLayout = function getLayout(page: ReactElement) { Cron_runsTablesPage.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
portal='admin'
permission={'READ_CRON_RUNS'} permission={'READ_CRON_RUNS'}

View File

@ -354,6 +354,7 @@ const Cron_runsView = () => {
Cron_runsView.getLayout = function getLayout(page: ReactElement) { Cron_runsView.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
portal='admin'
permission={'READ_CRON_RUNS'} permission={'READ_CRON_RUNS'}

View File

@ -1,431 +1,546 @@
import * as icon from '@mdi/js'; import * as icon from '@mdi/js'
import Head from 'next/head' import Head from 'next/head'
import React from 'react' import React from 'react'
import axios from 'axios'; import axios from 'axios'
import type { ReactElement } from 'react' import type { ReactElement } from 'react'
import Link from 'next/link'
import LayoutAuthenticated from '../layouts/Authenticated' import LayoutAuthenticated from '../layouts/Authenticated'
import SectionMain from '../components/SectionMain' import SectionMain from '../components/SectionMain'
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton' import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
import BaseIcon from "../components/BaseIcon"; import BaseIcon from '../components/BaseIcon'
import BaseButton from '../components/BaseButton'
import CardBox from '../components/CardBox'
import { getPageTitle } from '../config' import { getPageTitle } from '../config'
import Link from "next/link";
import { hasPermission } from "../helpers/userPermissions"; import { hasPermission } from '../helpers/userPermissions'
import { fetchWidgets } from '../stores/roles/rolesSlice'; import { getBusinessMenuLabel } from '../helpers/businessPlanLabels'
import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator'; import { getPortalLabel, isInternalAdmin } from '../helpers/portalRoles'
import { SmartWidget } from '../components/SmartWidget/SmartWidget'; import { fetchWidgets } from '../stores/roles/rolesSlice'
import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator'
import { SmartWidget } from '../components/SmartWidget/SmartWidget'
import { useAppDispatch, useAppSelector } from '../stores/hooks'
type EntityKey =
| 'users'
| 'roles'
| 'permissions'
| 'businesses'
| 'customers'
| 'transactions'
| 'review_requests'
| 'stripe_events'
| 'email_delivery_logs'
| 'cron_runs'
type CountValue = string | number | null
type CountState = Record<EntityKey, CountValue>
type DashboardCard = {
key: EntityKey
label: string
description: string
href: string
iconPath: string
permission: string
}
type DashboardAction = {
label: string
href: string
permission?: string | string[]
}
type DashboardActionGroup = {
title: string
description: string
actions: DashboardAction[]
}
const loadingMessage = 'Loading...'
const entityKeys: EntityKey[] = [
'users',
'roles',
'permissions',
'businesses',
'customers',
'transactions',
'review_requests',
'stripe_events',
'email_delivery_logs',
'cron_runs',
]
const entityConfig: Record<EntityKey, { endpoint: string; permission: string }> = {
users: { endpoint: 'users', permission: 'READ_USERS' },
roles: { endpoint: 'roles', permission: 'READ_ROLES' },
permissions: { endpoint: 'permissions', permission: 'READ_PERMISSIONS' },
businesses: { endpoint: 'businesses', permission: 'READ_BUSINESSES' },
customers: { endpoint: 'customers', permission: 'READ_CUSTOMERS' },
transactions: { endpoint: 'transactions', permission: 'READ_TRANSACTIONS' },
review_requests: { endpoint: 'review_requests', permission: 'READ_REVIEW_REQUESTS' },
stripe_events: { endpoint: 'stripe_events', permission: 'READ_STRIPE_EVENTS' },
email_delivery_logs: { endpoint: 'email_delivery_logs', permission: 'READ_EMAIL_DELIVERY_LOGS' },
cron_runs: { endpoint: 'cron_runs', permission: 'READ_CRON_RUNS' },
}
const initialCounts = entityKeys.reduce((counts, key) => {
counts[key] = loadingMessage
return counts
}, {} as CountState)
const storeIcon =
'mdiStore' in icon
? icon['mdiStore' as keyof typeof icon]
: icon.mdiTable
const accountMultipleIcon =
'mdiAccountMultiple' in icon
? icon['mdiAccountMultiple' as keyof typeof icon]
: icon.mdiTable
const emailFastIcon =
'mdiEmailFastOutline' in icon
? icon['mdiEmailFastOutline' as keyof typeof icon]
: icon.mdiTable
const webhookIcon =
'mdiWebhook' in icon
? icon['mdiWebhook' as keyof typeof icon]
: icon.mdiTable
const emailCheckIcon =
'mdiEmailCheckOutline' in icon
? icon['mdiEmailCheckOutline' as keyof typeof icon]
: icon.mdiTable
const clockIcon =
'mdiClockOutline' in icon
? icon['mdiClockOutline' as keyof typeof icon]
: icon.mdiTable
function formatCount(value: CountValue) {
if (value === null || value === undefined) return '—'
if (typeof value === 'number') return value.toLocaleString()
return value
}
function StatCard({
card,
value,
corners,
cardsStyle,
iconsColor,
}: {
card: DashboardCard
value: CountValue
corners: string
cardsStyle: string
iconsColor: string
}) {
return (
<Link href={card.href}>
<div
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6 h-full hover:shadow-lg transition-shadow`}
>
<div className='flex justify-between gap-4'>
<div>
<div className='text-sm font-black uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400'>
{card.label}
</div>
<div className='mt-2 text-3xl leading-tight font-semibold'>
{formatCount(value)}
</div>
<p className='mt-3 text-sm text-gray-500 dark:text-gray-400'>
{card.description}
</p>
</div>
<BaseIcon
className={`${iconsColor} flex-none`}
w='w-16'
h='h-16'
size={48}
path={card.iconPath}
/>
</div>
</div>
</Link>
)
}
function ActionGroupCard({ group, currentUser }: { group: DashboardActionGroup; currentUser: any }) {
const visibleActions = group.actions.filter(
(action) => !action.permission || hasPermission(currentUser, action.permission),
)
if (!visibleActions.length) return null
return (
<CardBox className='h-full'>
<div className='flex h-full flex-col'>
<div>
<h2 className='text-xl font-semibold'>{group.title}</h2>
<p className='mt-2 text-sm text-gray-500 dark:text-gray-400'>{group.description}</p>
</div>
<div className='mt-6 grid gap-3'>
{visibleActions.map((action) => (
<BaseButton
key={`${group.title}-${action.href}`}
href={action.href}
label={action.label}
color='whiteDark'
className='w-full justify-start'
/>
))}
</div>
</div>
</CardBox>
)
}
function PortalIntroCard({ currentUser, adminPortal }: { currentUser: any; adminPortal: boolean }) {
const portalLabel = getPortalLabel(currentUser)
const roleName = currentUser?.app_role?.name || 'User'
const name = currentUser?.firstName || currentUser?.email || 'there'
return (
<CardBox className='mb-6 overflow-hidden'>
<div className='grid gap-6 lg:grid-cols-[1.4fr_0.8fr] lg:items-center'>
<div>
<p className='text-xs font-black uppercase tracking-[0.25em] text-indigo-500 dark:text-indigo-300'>
{portalLabel}
</p>
<h2 className='mt-3 text-2xl font-bold'>Welcome, {name}</h2>
<p className='mt-3 text-gray-600 dark:text-gray-300'>
{adminPortal
? 'This internal area is for running the SaaS business: customer accounts, business profiles, billing events, review operations, and access control.'
: 'This customer workspace is for setting up your business profile, connecting review automation, managing customers, tracking transactions, and handling your subscription.'}
</p>
</div>
<div className='rounded-3xl bg-slate-950 p-6 text-white'>
<p className='text-sm text-slate-300'>Signed in as</p>
<p className='mt-2 text-2xl font-bold'>{roleName}</p>
<p className='mt-4 text-sm text-slate-300'>
{adminPortal
? 'Customer workspace setup links are intentionally hidden from this portal.'
: 'Internal platform administration links are intentionally hidden from this workspace.'}
</p>
</div>
</div>
</CardBox>
)
}
import { useAppDispatch, useAppSelector } from '../stores/hooks';
const Dashboard = () => { const Dashboard = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch()
const iconsColor = useAppSelector((state) => state.style.iconsColor); const iconsColor = useAppSelector((state) => state.style.iconsColor)
const corners = useAppSelector((state) => state.style.corners); const corners = useAppSelector((state) => state.style.corners)
const cardsStyle = useAppSelector((state) => state.style.cardsStyle); const cardsStyle = useAppSelector((state) => state.style.cardsStyle)
const { currentUser } = useAppSelector((state) => state.auth)
const { isFetchingQuery } = useAppSelector((state) => state.openAi)
const { rolesWidgets, loading } = useAppSelector((state) => state.roles)
const [counts, setCounts] = React.useState<CountState>(initialCounts)
const [widgetsRole, setWidgetsRole] = React.useState({
role: { value: '', label: '' },
})
const loadingMessage = 'Loading...'; const adminPortal = isInternalAdmin(currentUser)
const businessLabel = getBusinessMenuLabel(currentUser?.subscriptionPlanId)
const businessProfilesLabel = adminPortal ? 'Business profiles' : businessLabel
const loadData = React.useCallback(async () => {
const [users, setUsers] = React.useState(loadingMessage); if (!currentUser) return
const [roles, setRoles] = React.useState(loadingMessage);
const [permissions, setPermissions] = React.useState(loadingMessage);
const [businesses, setBusinesses] = React.useState(loadingMessage);
const [customers, setCustomers] = React.useState(loadingMessage);
const [transactions, setTransactions] = React.useState(loadingMessage);
const [review_requests, setReview_requests] = React.useState(loadingMessage);
const [stripe_events, setStripe_events] = React.useState(loadingMessage);
const [email_delivery_logs, setEmail_delivery_logs] = React.useState(loadingMessage);
const [cron_runs, setCron_runs] = React.useState(loadingMessage);
const requests = entityKeys.map(async (key) => {
const [widgetsRole, setWidgetsRole] = React.useState({ const config = entityConfig[key]
role: { value: '', label: '' },
});
const { currentUser } = useAppSelector((state) => state.auth);
const { isFetchingQuery } = useAppSelector((state) => state.openAi);
const { rolesWidgets, loading } = useAppSelector((state) => state.roles);
async function loadData() {
const entities = ['users','roles','permissions','businesses','customers','transactions','review_requests','stripe_events','email_delivery_logs','cron_runs',];
const fns = [setUsers,setRoles,setPermissions,setBusinesses,setCustomers,setTransactions,setReview_requests,setStripe_events,setEmail_delivery_logs,setCron_runs,];
const requests = entities.map((entity, index) => { if (!hasPermission(currentUser, config.permission)) {
return { key, count: null }
if(hasPermission(currentUser, `READ_${entity.toUpperCase()}`)) { }
return axios.get(`/${entity.toLowerCase()}/count`);
} else {
fns[index](null);
return Promise.resolve({data: {count: null}});
}
});
Promise.allSettled(requests).then((results) => { const response = await axios.get(`/${config.endpoint}/count`)
results.forEach((result, i) => {
if (result.status === 'fulfilled') { return { key, count: response.data.count as CountValue }
fns[i](result.value.data.count); })
} else {
fns[i](result.reason.message); const results = await Promise.allSettled(requests)
}
}); setCounts((previousCounts) => {
}); const nextCounts = { ...previousCounts }
}
results.forEach((result, index) => {
async function getWidgets(roleId) { const key = entityKeys[index]
await dispatch(fetchWidgets(roleId));
} if (result.status === 'fulfilled') {
React.useEffect(() => { nextCounts[result.value.key] = result.value.count
if (!currentUser) return; } else {
loadData().then(); console.error(`Failed to load ${key} dashboard count:`, result.reason)
setWidgetsRole({ role: { value: currentUser?.app_role?.id, label: currentUser?.app_role?.name } }); nextCounts[key] = 'Error'
}, [currentUser]); }
})
return nextCounts
})
}, [currentUser])
const getWidgets = React.useCallback(async (roleId: string) => {
await dispatch(fetchWidgets(roleId))
}, [dispatch])
React.useEffect(() => {
if (!currentUser) return
loadData().then()
setWidgetsRole({
role: {
value: currentUser?.app_role?.id,
label: currentUser?.app_role?.name,
},
})
}, [currentUser, loadData])
React.useEffect(() => {
if (!currentUser || !widgetsRole?.role?.value || adminPortal) return
getWidgets(widgetsRole?.role?.value || '').then()
}, [adminPortal, currentUser, getWidgets, widgetsRole?.role?.value])
const adminCards: DashboardCard[] = [
{
key: 'users',
label: 'Customer accounts',
description: 'Owners and team users across the platform.',
href: '/users/users-list',
iconPath: icon.mdiAccountGroup,
permission: 'READ_USERS',
},
{
key: 'businesses',
label: 'Business profiles',
description: 'Business locations connected to review flows.',
href: '/businesses/businesses-list',
iconPath: storeIcon,
permission: 'READ_BUSINESSES',
},
{
key: 'transactions',
label: 'Transactions',
description: 'Payment records feeding review requests.',
href: '/transactions/transactions-list',
iconPath: icon.mdiCreditCardOutline,
permission: 'READ_TRANSACTIONS',
},
{
key: 'review_requests',
label: 'Review requests',
description: 'Review invitations generated by the system.',
href: '/review_requests/review_requests-list',
iconPath: emailFastIcon,
permission: 'READ_REVIEW_REQUESTS',
},
{
key: 'stripe_events',
label: 'Payment events',
description: 'Stripe webhook events and processing status.',
href: '/stripe_events/stripe_events-list',
iconPath: webhookIcon,
permission: 'READ_STRIPE_EVENTS',
},
{
key: 'cron_runs',
label: 'Automation runs',
description: 'Scheduled background job execution history.',
href: '/cron_runs/cron_runs-list',
iconPath: clockIcon,
permission: 'READ_CRON_RUNS',
},
]
const customerCards: DashboardCard[] = [
{
key: 'businesses',
label: businessProfilesLabel,
description: 'Your business profile and Google review destination.',
href: '/businesses/businesses-list',
iconPath: storeIcon,
permission: 'READ_BUSINESSES',
},
{
key: 'customers',
label: 'Customers',
description: 'Customer records created from payments or imports.',
href: '/customers/customers-list',
iconPath: accountMultipleIcon,
permission: 'READ_CUSTOMERS',
},
{
key: 'transactions',
label: 'Transactions',
description: 'Payments that can trigger review follow-up.',
href: '/transactions/transactions-list',
iconPath: icon.mdiCreditCardOutline,
permission: 'READ_TRANSACTIONS',
},
{
key: 'review_requests',
label: 'Review requests',
description: 'Messages scheduled or sent to customers.',
href: '/review_requests/review_requests-list',
iconPath: emailFastIcon,
permission: 'READ_REVIEW_REQUESTS',
},
{
key: 'email_delivery_logs',
label: 'Email delivery',
description: 'Delivery activity for review request emails.',
href: '/email_delivery_logs/email_delivery_logs-list',
iconPath: emailCheckIcon,
permission: 'READ_EMAIL_DELIVERY_LOGS',
},
]
const adminActionGroups: DashboardActionGroup[] = [
{
title: 'Customer operations',
description: 'Support customer accounts and the business profiles they manage.',
actions: [
{ label: 'Review customer accounts', href: '/users/users-list', permission: 'READ_USERS' },
{ label: 'Review business profiles', href: '/businesses/businesses-list', permission: 'READ_BUSINESSES' },
{ label: 'Review end customers', href: '/customers/customers-list', permission: 'READ_CUSTOMERS' },
],
},
{
title: 'Billing & review operations',
description: 'Monitor payment data, webhook events, review requests, and delivery health.',
actions: [
{ label: 'View transactions', href: '/transactions/transactions-list', permission: 'READ_TRANSACTIONS' },
{ label: 'View payment events', href: '/stripe_events/stripe_events-list', permission: 'READ_STRIPE_EVENTS' },
{ label: 'View review requests', href: '/review_requests/review_requests-list', permission: 'READ_REVIEW_REQUESTS' },
{ label: 'View email delivery logs', href: '/email_delivery_logs/email_delivery_logs-list', permission: 'READ_EMAIL_DELIVERY_LOGS' },
{ label: 'View automation runs', href: '/cron_runs/cron_runs-list', permission: 'READ_CRON_RUNS' },
],
},
{
title: 'Platform access control',
description: 'Manage internal roles and permissions for the platform.',
actions: [
{ label: 'Manage roles', href: '/roles/roles-list', permission: 'READ_ROLES' },
{ label: 'Manage permissions', href: '/permissions/permissions-list', permission: 'READ_PERMISSIONS' },
],
},
]
const customerActionGroups: DashboardActionGroup[] = [
{
title: 'Review automation setup',
description: 'Configure the business profile, review request templates, and payment triggers.',
actions: [
{ label: 'Open Review Flow', href: '/reviewflow' },
{ label: `Manage ${businessLabel}`, href: '/businesses/businesses-list', permission: 'READ_BUSINESSES' },
{ label: 'Manage review requests', href: '/review_requests/review_requests-list', permission: 'READ_REVIEW_REQUESTS' },
],
},
{
title: 'Customer records',
description: 'Track customers and transactions that power follow-up messages.',
actions: [
{ label: 'Manage customers', href: '/customers/customers-list', permission: 'READ_CUSTOMERS' },
{ label: 'View transactions', href: '/transactions/transactions-list', permission: 'READ_TRANSACTIONS' },
{ label: 'View email delivery', href: '/email_delivery_logs/email_delivery_logs-list', permission: 'READ_EMAIL_DELIVERY_LOGS' },
],
},
{
title: 'Plan & billing',
description: 'Review plan limits, usage, and billing status for this workspace.',
actions: [
{ label: 'Open subscription', href: '/subscription' },
],
},
]
const cards = adminPortal ? adminCards : customerCards
const actionGroups = adminPortal ? adminActionGroups : customerActionGroups
const visibleCards = cards.filter((card) => hasPermission(currentUser, card.permission))
const title = adminPortal ? 'Internal admin portal' : 'Customer workspace'
const sectionIcon = adminPortal ? icon.mdiShieldAccountVariantOutline : icon.mdiChartTimelineVariant
const showCustomerWidgets = !adminPortal && hasPermission(currentUser, 'CREATE_ROLES')
React.useEffect(() => {
if (!currentUser || !widgetsRole?.role?.value) return;
getWidgets(widgetsRole?.role?.value || '').then();
}, [widgetsRole?.role?.value]);
return ( return (
<> <>
<Head> <Head>
<title> <title>{getPageTitle(title)}</title>
{getPageTitle('Overview')}
</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton <SectionTitleLineWithButton icon={sectionIcon} title={title} main>
icon={icon.mdiChartTimelineVariant}
title='Overview'
main>
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
{hasPermission(currentUser, 'CREATE_ROLES') && <WidgetCreator <PortalIntroCard currentUser={currentUser} adminPortal={adminPortal} />
{showCustomerWidgets && (
<WidgetCreator
currentUser={currentUser} currentUser={currentUser}
isFetchingQuery={isFetchingQuery} isFetchingQuery={isFetchingQuery}
setWidgetsRole={setWidgetsRole} setWidgetsRole={setWidgetsRole}
widgetsRole={widgetsRole} widgetsRole={widgetsRole}
/>} />
{!!rolesWidgets.length && )}
hasPermission(currentUser, 'CREATE_ROLES') && ( {!!rolesWidgets.length && showCustomerWidgets && (
<p className=' text-gray-500 dark:text-gray-400 mb-4'> <p className='mb-4 text-gray-500 dark:text-gray-400'>
{`${widgetsRole?.role?.label || 'Users'}'s widgets`} {`${widgetsRole?.role?.label || 'Users'}'s widgets`}
</p> </p>
)} )}
<div className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-6 grid-flow-dense'> {!adminPortal && (
<div className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-6 grid-flow-dense'>
{(isFetchingQuery || loading) && ( {(isFetchingQuery || loading) && (
<div className={` ${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 text-lg leading-tight text-gray-500 flex items-center ${cardsStyle} dark:border-dark-700 p-6`}> <div className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 text-lg leading-tight text-gray-500 flex items-center ${cardsStyle} dark:border-dark-700 p-6`}>
<BaseIcon <BaseIcon
className={`${iconsColor} animate-spin mr-5`} className={`${iconsColor} animate-spin mr-5`}
w='w-16' w='w-16'
h='h-16' h='h-16'
size={48} size={48}
path={icon.mdiLoading} path={icon.mdiLoading}
/>{' '} />{' '}
Loading widgets... Loading widgets...
</div> </div>
)} )}
{ rolesWidgets && {rolesWidgets.map((widget) => (
rolesWidgets.map((widget) => ( <SmartWidget
<SmartWidget key={widget.id}
key={widget.id} userId={currentUser?.id}
userId={currentUser?.id} widget={widget}
widget={widget} roleId={widgetsRole?.role?.value || ''}
roleId={widgetsRole?.role?.value || ''} admin={false}
admin={hasPermission(currentUser, 'CREATE_ROLES')} />
/>
))} ))}
</div>
)}
{!adminPortal && !!rolesWidgets.length && <hr className='my-6' />}
<div id='dashboard' className='grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6'>
{visibleCards.map((card) => (
<StatCard
key={`${card.key}-${card.label}`}
card={card}
value={counts[card.key]}
corners={corners}
cardsStyle={cardsStyle}
iconsColor={iconsColor}
/>
))}
</div> </div>
{!!rolesWidgets.length && <hr className='my-6 ' />} <div className='grid grid-cols-1 gap-6 lg:grid-cols-3'>
{actionGroups.map((group) => (
<div id="dashboard" className='grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6'> <ActionGroupCard key={group.title} group={group} currentUser={currentUser} />
))}
{hasPermission(currentUser, 'READ_USERS') && <Link href={'/users/users-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Users
</div>
<div className="text-3xl leading-tight font-semibold">
{users}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={icon.mdiAccountGroup || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_ROLES') && <Link href={'/roles/roles-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Roles
</div>
<div className="text-3xl leading-tight font-semibold">
{roles}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={icon.mdiShieldAccountVariantOutline || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_PERMISSIONS') && <Link href={'/permissions/permissions-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Permissions
</div>
<div className="text-3xl leading-tight font-semibold">
{permissions}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={icon.mdiShieldAccountOutline || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_BUSINESSES') && <Link href={'/businesses/businesses-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Businesses
</div>
<div className="text-3xl leading-tight font-semibold">
{businesses}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiStore' in icon ? icon['mdiStore' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_CUSTOMERS') && <Link href={'/customers/customers-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Customers
</div>
<div className="text-3xl leading-tight font-semibold">
{customers}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiAccountMultiple' in icon ? icon['mdiAccountMultiple' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_TRANSACTIONS') && <Link href={'/transactions/transactions-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Transactions
</div>
<div className="text-3xl leading-tight font-semibold">
{transactions}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiCreditCardOutline' in icon ? icon['mdiCreditCardOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_REVIEW_REQUESTS') && <Link href={'/review_requests/review_requests-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Review requests
</div>
<div className="text-3xl leading-tight font-semibold">
{review_requests}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiEmailFastOutline' in icon ? icon['mdiEmailFastOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_STRIPE_EVENTS') && <Link href={'/stripe_events/stripe_events-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Stripe events
</div>
<div className="text-3xl leading-tight font-semibold">
{stripe_events}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiWebhook' in icon ? icon['mdiWebhook' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_EMAIL_DELIVERY_LOGS') && <Link href={'/email_delivery_logs/email_delivery_logs-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Email delivery logs
</div>
<div className="text-3xl leading-tight font-semibold">
{email_delivery_logs}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiEmailCheckOutline' in icon ? icon['mdiEmailCheckOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_CRON_RUNS') && <Link href={'/cron_runs/cron_runs-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Cron runs
</div>
<div className="text-3xl leading-tight font-semibold">
{cron_runs}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiClockOutline' in icon ? icon['mdiClockOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
</div> </div>
</SectionMain> </SectionMain>
</> </>

View File

@ -7,6 +7,7 @@ import CardBox from '../components/CardBox';
import LayoutGuest from '../layouts/Guest'; import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config'; import { getPageTitle } from '../config';
import { subscriptionPlans, trialDays } from '../subscriptionPlans'; import { subscriptionPlans, trialDays } from '../subscriptionPlans';
import { getBusinessProfileNoun } from '../helpers/businessPlanLabels';
const metrics = [ const metrics = [
['7 days', 'default review delay'], ['7 days', 'default review delay'],
@ -24,7 +25,7 @@ const features = [
'Business review links and templates', 'Business review links and templates',
'Webhook-created customers and transactions', 'Webhook-created customers and transactions',
'Readable queue with message preview', 'Readable queue with message preview',
'Admin CRUD and API docs still available', 'Internal admin controls stay separate from customer workspaces',
]; ];
export default function Starter() { export default function Starter() {
@ -49,7 +50,7 @@ export default function Starter() {
<nav className="flex items-center gap-3"> <nav className="flex items-center gap-3">
<BaseButton href="/#pricing" label="Pricing" color="whiteDark" /> <BaseButton href="/#pricing" label="Pricing" color="whiteDark" />
<BaseButton href="/login" icon={mdiLogin} label="Login" color="whiteDark" /> <BaseButton href="/login" icon={mdiLogin} label="Login" color="whiteDark" />
<BaseButton href="/reviewflow" icon={mdiArrowRight} label="Admin interface" color="info" /> <BaseButton href="/reviewflow" icon={mdiArrowRight} label="Review Flow workspace" color="info" />
</nav> </nav>
</div> </div>
</header> </header>
@ -71,7 +72,7 @@ export default function Starter() {
</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" />
<BaseButton href="/login" icon={mdiShieldCheckOutline} label="Login to admin" color="whiteDark" /> <BaseButton href="/login" icon={mdiShieldCheckOutline} label="Log in to workspace" color="whiteDark" />
</div> </div>
<div className="mt-10 grid max-w-2xl gap-3 sm:grid-cols-3"> <div className="mt-10 grid max-w-2xl gap-3 sm:grid-cols-3">
{metrics.map(([value, label]) => ( {metrics.map(([value, label]) => (
@ -181,7 +182,7 @@ export default function Starter() {
</div> </div>
<div> <div>
<p className="text-2xl font-black text-slate-950">{plan.limits.businesses}</p> <p className="text-2xl font-black text-slate-950">{plan.limits.businesses}</p>
<p className="text-sm text-slate-500">businesses/locations</p> <p className="text-sm text-slate-500">{getBusinessProfileNoun(plan.limits.businesses)}</p>
</div> </div>
<div> <div>
<p className="text-2xl font-black text-slate-950">{plan.limits.teamMembers}</p> <p className="text-2xl font-black text-slate-950">{plan.limits.teamMembers}</p>
@ -230,7 +231,7 @@ export default function Starter() {
<p className="text-sm font-black uppercase tracking-[0.3em] text-emerald-300">First MVP slice</p> <p className="text-sm font-black uppercase tracking-[0.3em] text-emerald-300">First MVP slice</p>
<h2 className="mt-4 text-4xl font-black tracking-tight md:text-5xl">A complete thin workflow, not just a screen.</h2> <h2 className="mt-4 text-4xl font-black tracking-tight md:text-5xl">A complete thin workflow, not just a screen.</h2>
<p className="mt-5 leading-8 text-slate-300"> <p className="mt-5 leading-8 text-slate-300">
The admin workspace lets a user connect payment webhooks, receive events, create transactions and customers, queue review requests, browse recent activity, and inspect the generated message. The customer workspace lets an account owner connect payment webhooks, receive events, create transactions and customers, queue review requests, browse recent activity, and inspect the generated message. Internal admin users stay separate for support and operations.
</p> </p>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">

View File

@ -46,8 +46,8 @@ export default function Login() {
const appHighlights = [ const appHighlights = [
'Automated review requests after payments, jobs, or service milestones.', 'Automated review requests after payments, jobs, or service milestones.',
'Customer, business, transaction, and delivery follow-up data in one admin workspace.', 'Customer, business, transaction, and delivery follow-up data in one customer workspace.',
'Dashboards, CRM records, payment events, email logs, and admin controls already built in.', 'Dashboards, CRM records, payment events, email logs, and separate internal admin controls already built in.',
]; ];
const competitorAdvantages = [ const competitorAdvantages = [
@ -87,7 +87,7 @@ export default function Login() {
title: 'Starter limits', title: 'Starter limits',
features: [ features: [
'250 review requests per month.', '250 review requests per month.',
'1 business or location.', '1 business profile.',
'2 team members.', '2 team members.',
'Stripe, Square, PayPal, Shopify, and WooCommerce webhook intake.', 'Stripe, Square, PayPal, Shopify, and WooCommerce webhook intake.',
], ],
@ -104,7 +104,7 @@ export default function Login() {
title: 'Everything in Starter', title: 'Everything in Starter',
features: [ features: [
'2,500 review requests per month.', '2,500 review requests per month.',
'10 businesses or locations.', '10 business profiles.',
'10 team members.', '10 team members.',
'Priority support and advanced reporting.', 'Priority support and advanced reporting.',
], ],
@ -222,13 +222,13 @@ export default function Login() {
data-password="fc6e39e3" data-password="fc6e39e3"
onClick={(e) => setLogin(e.target)}>admin@flatlogic.com</code>{' / '} onClick={(e) => setLogin(e.target)}>admin@flatlogic.com</code>{' / '}
<code className={`${textColor}`}>fc6e39e3</code>{' / '} <code className={`${textColor}`}>fc6e39e3</code>{' / '}
to login as Admin</p> to login as Internal Admin</p>
<p>Use <code <p>Use <code
className={`cursor-pointer ${textColor} `} className={`cursor-pointer ${textColor} `}
data-password="874c3b951385" data-password="874c3b951385"
onClick={(e) => setLogin(e.target)}>client@hello.com</code>{' / '} onClick={(e) => setLogin(e.target)}>john@doe.com</code>{' / '}
<code className={`${textColor}`}>874c3b951385</code>{' / '} <code className={`${textColor}`}>874c3b951385</code>{' / '}
to login as User</p> to login as Customer Owner</p>
</div> </div>
<div> <div>
<BaseIcon <BaseIcon
@ -316,7 +316,7 @@ export default function Login() {
Review Flow helps logistics and transportation businesses turn completed jobs, payments, Review Flow helps logistics and transportation businesses turn completed jobs, payments,
and customer interactions into organized review requests. Your team can manage customer 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 records, monitor follow-up, and keep reputation-building work moving from one secure
admin panel. workspace.
</p> </p>
</div> </div>

View File

@ -175,6 +175,7 @@ const EditPermissions = () => {
EditPermissions.getLayout = function getLayout(page: ReactElement) { EditPermissions.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
portal='admin'
permission={'UPDATE_PERMISSIONS'} permission={'UPDATE_PERMISSIONS'}

View File

@ -172,6 +172,7 @@ const EditPermissionsPage = () => {
EditPermissionsPage.getLayout = function getLayout(page: ReactElement) { EditPermissionsPage.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
portal='admin'
permission={'UPDATE_PERMISSIONS'} permission={'UPDATE_PERMISSIONS'}

View File

@ -150,6 +150,7 @@ const PermissionsTablesPage = () => {
PermissionsTablesPage.getLayout = function getLayout(page: ReactElement) { PermissionsTablesPage.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
portal='admin'
permission={'READ_PERMISSIONS'} permission={'READ_PERMISSIONS'}

View File

@ -131,6 +131,7 @@ const PermissionsNew = () => {
PermissionsNew.getLayout = function getLayout(page: ReactElement) { PermissionsNew.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
portal='admin'
permission={'CREATE_PERMISSIONS'} permission={'CREATE_PERMISSIONS'}

View File

@ -148,6 +148,7 @@ const PermissionsTablesPage = () => {
PermissionsTablesPage.getLayout = function getLayout(page: ReactElement) { PermissionsTablesPage.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
portal='admin'
permission={'READ_PERMISSIONS'} permission={'READ_PERMISSIONS'}

View File

@ -115,6 +115,7 @@ const PermissionsView = () => {
PermissionsView.getLayout = function getLayout(page: ReactElement) { PermissionsView.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
portal='admin'
permission={'READ_PERMISSIONS'} permission={'READ_PERMISSIONS'}

View File

@ -27,6 +27,7 @@ import SectionMain from '../components/SectionMain';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import LayoutAuthenticated from '../layouts/Authenticated'; import LayoutAuthenticated from '../layouts/Authenticated';
import { getPageTitle } from '../config'; import { getPageTitle } from '../config';
import { getBusinessProfileLimitLabel } from '../helpers/businessPlanLabels';
interface ReviewBusiness { interface ReviewBusiness {
id?: string; id?: string;
@ -412,7 +413,7 @@ export default function ReviewFlowWorkspace() {
currentSubscription.trialDaysLeft !== undefined currentSubscription.trialDaysLeft !== undefined
? `${currentSubscription.trialDaysLeft} trial days left. ` ? `${currentSubscription.trialDaysLeft} trial days left. `
: ''} : ''}
{reviewRequestsRemaining.toLocaleString()} review requests and {businessesRemaining.toLocaleString()} business slots remaining on this plan. {reviewRequestsRemaining.toLocaleString()} review requests and {getBusinessProfileLimitLabel(businessesRemaining)} remaining on this plan.
</p> </p>
</div> </div>
<div className='grid gap-3 md:grid-cols-[1fr_auto] md:items-center'> <div className='grid gap-3 md:grid-cols-[1fr_auto] md:items-center'>
@ -476,7 +477,7 @@ export default function ReviewFlowWorkspace() {
Unlock advanced reputation growth tools. Unlock advanced reputation growth tools.
</h3> </h3>
<p className='mt-3 text-slate-300'> <p className='mt-3 text-slate-300'>
Starter keeps the core review workflow running. Pro raises limits and unlocks the next automation, AI, and marketing modules as they are enabled. Starter keeps the core review workflow running. Pro raises limits to 10 business profiles and unlocks the next automation, AI, and marketing modules as they are enabled.
</p> </p>
<BaseButton <BaseButton
href='/subscription' href='/subscription'
@ -896,5 +897,5 @@ export default function ReviewFlowWorkspace() {
} }
ReviewFlowWorkspace.getLayout = function getLayout(page: ReactElement) { ReviewFlowWorkspace.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>; return <LayoutAuthenticated portal='customer'>{page}</LayoutAuthenticated>;
}; };

View File

@ -264,6 +264,7 @@ const EditRoles = () => {
EditRoles.getLayout = function getLayout(page: ReactElement) { EditRoles.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
portal='admin'
permission={'UPDATE_ROLES'} permission={'UPDATE_ROLES'}

View File

@ -261,6 +261,7 @@ const EditRolesPage = () => {
EditRolesPage.getLayout = function getLayout(page: ReactElement) { EditRolesPage.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
portal='admin'
permission={'UPDATE_ROLES'} permission={'UPDATE_ROLES'}

View File

@ -150,6 +150,7 @@ const RolesTablesPage = () => {
RolesTablesPage.getLayout = function getLayout(page: ReactElement) { RolesTablesPage.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
portal='admin'
permission={'READ_ROLES'} permission={'READ_ROLES'}

View File

@ -183,6 +183,7 @@ const RolesNew = () => {
RolesNew.getLayout = function getLayout(page: ReactElement) { RolesNew.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
portal='admin'
permission={'CREATE_ROLES'} permission={'CREATE_ROLES'}

View File

@ -148,6 +148,7 @@ const RolesTablesPage = () => {
RolesTablesPage.getLayout = function getLayout(page: ReactElement) { RolesTablesPage.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
portal='admin'
permission={'READ_ROLES'} permission={'READ_ROLES'}

View File

@ -292,6 +292,7 @@ const RolesView = () => {
RolesView.getLayout = function getLayout(page: ReactElement) { RolesView.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
portal='admin'
permission={'READ_ROLES'} permission={'READ_ROLES'}

View File

@ -665,6 +665,7 @@ const EditStripe_events = () => {
EditStripe_events.getLayout = function getLayout(page: ReactElement) { EditStripe_events.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
portal='admin'
permission={'UPDATE_STRIPE_EVENTS'} permission={'UPDATE_STRIPE_EVENTS'}

View File

@ -662,6 +662,7 @@ const EditStripe_eventsPage = () => {
EditStripe_eventsPage.getLayout = function getLayout(page: ReactElement) { EditStripe_eventsPage.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
portal='admin'
permission={'UPDATE_STRIPE_EVENTS'} permission={'UPDATE_STRIPE_EVENTS'}

View File

@ -154,6 +154,7 @@ const Stripe_eventsTablesPage = () => {
Stripe_eventsTablesPage.getLayout = function getLayout(page: ReactElement) { Stripe_eventsTablesPage.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
portal='admin'
permission={'READ_STRIPE_EVENTS'} permission={'READ_STRIPE_EVENTS'}

View File

@ -482,6 +482,7 @@ const Stripe_eventsNew = () => {
Stripe_eventsNew.getLayout = function getLayout(page: ReactElement) { Stripe_eventsNew.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
portal='admin'
permission={'CREATE_STRIPE_EVENTS'} permission={'CREATE_STRIPE_EVENTS'}

View File

@ -156,6 +156,7 @@ const Stripe_eventsTablesPage = () => {
Stripe_eventsTablesPage.getLayout = function getLayout(page: ReactElement) { Stripe_eventsTablesPage.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
portal='admin'
permission={'READ_STRIPE_EVENTS'} permission={'READ_STRIPE_EVENTS'}

View File

@ -380,6 +380,7 @@ const Stripe_eventsView = () => {
Stripe_eventsView.getLayout = function getLayout(page: ReactElement) { Stripe_eventsView.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
portal='admin'
permission={'READ_STRIPE_EVENTS'} permission={'READ_STRIPE_EVENTS'}

View File

@ -15,6 +15,7 @@ import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton
import LayoutAuthenticated from '../layouts/Authenticated' import LayoutAuthenticated from '../layouts/Authenticated'
import { getPageTitle } from '../config' import { getPageTitle } from '../config'
import { SubscriptionPlan } from '../subscriptionPlans' import { SubscriptionPlan } from '../subscriptionPlans'
import { getBusinessProfileNoun, getBusinessProfileUsageLabel } from '../helpers/businessPlanLabels'
type SubscriptionStatusResponse = { type SubscriptionStatusResponse = {
subscription: { subscription: {
@ -57,7 +58,7 @@ const usageLabels: Array<{
label: string label: string
}> = [ }> = [
{ key: 'monthlyReviewRequests', limitKey: 'monthlyReviewRequests', label: 'Review requests this month' }, { key: 'monthlyReviewRequests', limitKey: 'monthlyReviewRequests', label: 'Review requests this month' },
{ key: 'businesses', limitKey: 'businesses', label: 'Businesses / locations' }, { key: 'businesses', limitKey: 'businesses', label: 'Business profiles' },
{ key: 'teamMembers', limitKey: 'teamMembers', label: 'Team members' }, { key: 'teamMembers', limitKey: 'teamMembers', label: 'Team members' },
{ key: 'paymentConnectors', limitKey: 'paymentConnectors', label: 'Connected payment providers' }, { key: 'paymentConnectors', limitKey: 'paymentConnectors', label: 'Connected payment providers' },
] ]
@ -308,7 +309,9 @@ export default function SubscriptionPage() {
<div className='mb-3 flex items-center justify-between gap-3'> <div className='mb-3 flex items-center justify-between gap-3'>
<p className='font-black text-slate-900 dark:text-white'>{item.label}</p> <p className='font-black text-slate-900 dark:text-white'>{item.label}</p>
<p className={usageTextClass}> <p className={usageTextClass}>
{formatLimit(used)} / {formatLimit(limit)} {item.limitKey === 'businesses'
? getBusinessProfileUsageLabel(used, limit)
: `${formatLimit(used)} / ${formatLimit(limit)}`}
</p> </p>
</div> </div>
<div className='h-3 overflow-hidden rounded-full bg-slate-100 dark:bg-dark-800'> <div className='h-3 overflow-hidden rounded-full bg-slate-100 dark:bg-dark-800'>
@ -362,7 +365,7 @@ export default function SubscriptionPage() {
</div> </div>
<div> <div>
<p className='text-2xl font-black'>{formatLimit(plan.limits.businesses)}</p> <p className='text-2xl font-black'>{formatLimit(plan.limits.businesses)}</p>
<p className='text-sm text-slate-500'>businesses</p> <p className='text-sm text-slate-500'>{getBusinessProfileNoun(plan.limits.businesses)}</p>
</div> </div>
<div> <div>
<p className='text-2xl font-black'>{formatLimit(plan.limits.teamMembers)}</p> <p className='text-2xl font-black'>{formatLimit(plan.limits.teamMembers)}</p>
@ -405,5 +408,5 @@ export default function SubscriptionPage() {
} }
SubscriptionPage.getLayout = function getLayout(page: ReactElement) { SubscriptionPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated> return <LayoutAuthenticated portal='customer'>{page}</LayoutAuthenticated>
} }

View File

@ -698,6 +698,7 @@ const EditUsers = () => {
EditUsers.getLayout = function getLayout(page: ReactElement) { EditUsers.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
portal='admin'
permission={'UPDATE_USERS'} permission={'UPDATE_USERS'}

View File

@ -695,6 +695,7 @@ const EditUsersPage = () => {
EditUsersPage.getLayout = function getLayout(page: ReactElement) { EditUsersPage.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
portal='admin'
permission={'UPDATE_USERS'} permission={'UPDATE_USERS'}

View File

@ -154,6 +154,7 @@ const UsersTablesPage = () => {
UsersTablesPage.getLayout = function getLayout(page: ReactElement) { UsersTablesPage.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
portal='admin'
permission={'READ_USERS'} permission={'READ_USERS'}

View File

@ -495,6 +495,7 @@ const UsersNew = () => {
UsersNew.getLayout = function getLayout(page: ReactElement) { UsersNew.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
portal='admin'
permission={'CREATE_USERS'} permission={'CREATE_USERS'}

View File

@ -152,6 +152,7 @@ const UsersTablesPage = () => {
UsersTablesPage.getLayout = function getLayout(page: ReactElement) { UsersTablesPage.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
portal='admin'
permission={'READ_USERS'} permission={'READ_USERS'}

View File

@ -407,7 +407,7 @@ const UsersView = () => {
<> <>
<p className={'block font-bold mb-2'}>Businesses Owner</p> <p className={'block font-bold mb-2'}>Business profiles owned</p>
<CardBox <CardBox
className='mb-6 border border-gray-300 rounded overflow-hidden' className='mb-6 border border-gray-300 rounded overflow-hidden'
hasTable hasTable
@ -420,53 +420,53 @@ const UsersView = () => {
<th>BusinessName</th> <th>Business name</th>
<th>GoogleReviewLink</th> <th>Google review link</th>
<th>YelpReviewLink</th> <th>Yelp review link</th>
<th>FacebookReviewLink</th> <th>Facebook review link</th>
<th>DelayDays</th> <th>Review delay days</th>
<th>EmailSubjectTemplate</th> <th>Email subject template</th>
<th>IsActive</th> <th>Active</th>
<th>StripeAccountReference</th> <th>Stripe account reference</th>
<th>StripeConnected</th> <th>Stripe connected</th>
<th>StripeConnectedAt</th> <th>Stripe connected at</th>
<th>DefaultReviewPlatform</th> <th>Default review platform</th>
<th>CustomReviewLink</th> <th>Custom review link</th>
</tr> </tr>
@ -585,6 +585,7 @@ const UsersView = () => {
UsersView.getLayout = function getLayout(page: ReactElement) { UsersView.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
portal='admin'
permission={'READ_USERS'} permission={'READ_USERS'}

View File

@ -158,7 +158,7 @@ export const businessesSlice = createSlice({
builder.addCase(deleteItemsByIds.fulfilled, (state) => { builder.addCase(deleteItemsByIds.fulfilled, (state) => {
state.loading = false; state.loading = false;
fulfilledNotify(state, 'Businesses has been deleted'); fulfilledNotify(state, 'Businesses have been deleted');
}); });
builder.addCase(deleteItemsByIds.rejected, (state, action) => { builder.addCase(deleteItemsByIds.rejected, (state, action) => {
@ -173,7 +173,7 @@ export const businessesSlice = createSlice({
builder.addCase(deleteItem.fulfilled, (state) => { builder.addCase(deleteItem.fulfilled, (state) => {
state.loading = false state.loading = false
fulfilledNotify(state, `${'Businesses'.slice(0, -1)} has been deleted`); fulfilledNotify(state, 'Business has been deleted');
}) })
builder.addCase(deleteItem.rejected, (state, action) => { builder.addCase(deleteItem.rejected, (state, action) => {
@ -192,7 +192,7 @@ export const businessesSlice = createSlice({
builder.addCase(create.fulfilled, (state) => { builder.addCase(create.fulfilled, (state) => {
state.loading = false state.loading = false
fulfilledNotify(state, `${'Businesses'.slice(0, -1)} has been created`); fulfilledNotify(state, 'Business has been created');
}) })
builder.addCase(update.pending, (state) => { builder.addCase(update.pending, (state) => {
@ -201,7 +201,7 @@ export const businessesSlice = createSlice({
}) })
builder.addCase(update.fulfilled, (state) => { builder.addCase(update.fulfilled, (state) => {
state.loading = false state.loading = false
fulfilledNotify(state, `${'Businesses'.slice(0, -1)} has been updated`); fulfilledNotify(state, 'Business has been updated');
}) })
builder.addCase(update.rejected, (state, action) => { builder.addCase(update.rejected, (state, action) => {
state.loading = false state.loading = false
@ -214,7 +214,7 @@ export const businessesSlice = createSlice({
}) })
builder.addCase(uploadCsv.fulfilled, (state) => { builder.addCase(uploadCsv.fulfilled, (state) => {
state.loading = false; state.loading = false;
fulfilledNotify(state, 'Businesses has been uploaded'); fulfilledNotify(state, 'Businesses have been uploaded');
}) })
builder.addCase(uploadCsv.rejected, (state, action) => { builder.addCase(uploadCsv.rejected, (state, action) => {
state.loading = false; state.loading = false;

View File

@ -38,7 +38,7 @@ export const subscriptionPlans: SubscriptionPlan[] = [
'Manual review request creation', 'Manual review request creation',
'Hosted public review form', 'Hosted public review form',
'Customer management', 'Customer management',
'Business/location management', 'Business profile management',
'Transaction tracking', 'Transaction tracking',
'Stripe, Square, PayPal, Shopify, and WooCommerce webhook intake', 'Stripe, Square, PayPal, Shopify, and WooCommerce webhook intake',
'Review request status tracking', 'Review request status tracking',