Base
This commit is contained in:
parent
c7ec13b78b
commit
f741ab0364
@ -8,6 +8,7 @@ const {
|
||||
|
||||
const DEFAULT_PLAN_ID = 'starter';
|
||||
const DEFAULT_STATUS = 'trialing';
|
||||
const INTERNAL_ADMIN_ROLE_NAMES = ['Administrator'];
|
||||
const DAY_IN_MS = 24 * 60 * 60 * 1000;
|
||||
const PAYMENT_CONNECTOR_FIELDS = [
|
||||
'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 = {}) {
|
||||
const baseMessage = `${plan.name} includes ${limit.toLocaleString()} ${unit}. You have already used ${usageCount.toLocaleString()}.`;
|
||||
const upgradePrefix = plan.id === 'starter' ? 'Upgrade to Pro or ' : '';
|
||||
@ -568,6 +601,15 @@ module.exports = class SubscriptionService {
|
||||
const user = await getUserRecord(currentUserOrId, options);
|
||||
const subscription = getEffectiveSubscription(user);
|
||||
|
||||
if (await isSubscriptionLimitExemptUser(user, options)) {
|
||||
return {
|
||||
allowed: true,
|
||||
usage: null,
|
||||
subscription,
|
||||
subscriptionExempt: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!subscription.isActive) {
|
||||
return {
|
||||
allowed: false,
|
||||
@ -610,11 +652,20 @@ module.exports = class SubscriptionService {
|
||||
const user = await getUserRecord(currentUserOrId, options);
|
||||
const subscription = getEffectiveSubscription(user);
|
||||
|
||||
if (await isSubscriptionLimitExemptUser(user, options)) {
|
||||
return {
|
||||
allowed: true,
|
||||
usage: null,
|
||||
subscription,
|
||||
subscriptionExempt: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!subscription.isActive) {
|
||||
return {
|
||||
allowed: false,
|
||||
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,
|
||||
usage.businesses,
|
||||
limit,
|
||||
'businesses/locations',
|
||||
{ remediation: 'remove an existing business/location before adding another.' },
|
||||
limit === 1 ? 'business profile' : 'business profiles',
|
||||
{
|
||||
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 subscription = getEffectiveSubscription(user);
|
||||
|
||||
if (await isSubscriptionLimitExemptUser(user, options)) {
|
||||
return {
|
||||
allowed: true,
|
||||
usage: null,
|
||||
subscription,
|
||||
subscriptionExempt: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!subscription.isActive) {
|
||||
return {
|
||||
allowed: false,
|
||||
@ -694,6 +758,15 @@ module.exports = class SubscriptionService {
|
||||
const user = await getUserRecord(currentUserOrId, options);
|
||||
const subscription = getEffectiveSubscription(user);
|
||||
|
||||
if (await isSubscriptionLimitExemptUser(user, options)) {
|
||||
return {
|
||||
allowed: true,
|
||||
usage: null,
|
||||
subscription,
|
||||
subscriptionExempt: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!subscription.isActive) {
|
||||
return {
|
||||
allowed: false,
|
||||
@ -740,6 +813,10 @@ module.exports = class SubscriptionService {
|
||||
const user = await getUserRecord(currentUserOrId, options);
|
||||
const subscription = getEffectiveSubscription(user);
|
||||
|
||||
if (await isSubscriptionLimitExemptUser(user, options)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!subscription.isActive) {
|
||||
throw httpError('Your Review Flow trial has ended. Choose a plan to keep using this feature.', 403);
|
||||
}
|
||||
|
||||
@ -19,7 +19,7 @@ const subscriptionPlans = [
|
||||
'Manual review request creation',
|
||||
'Hosted public review form',
|
||||
'Customer management',
|
||||
'Business/location management',
|
||||
'Business profile management',
|
||||
'Transaction tracking',
|
||||
'Stripe, Square, PayPal, Shopify, and WooCommerce webhook intake',
|
||||
'Review request status tracking',
|
||||
|
||||
@ -90,7 +90,7 @@ const CardBusinesses = ({
|
||||
|
||||
|
||||
<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'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.name }
|
||||
@ -102,7 +102,7 @@ const CardBusinesses = ({
|
||||
|
||||
|
||||
<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'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.google_review_link }
|
||||
@ -114,7 +114,7 @@ const CardBusinesses = ({
|
||||
|
||||
|
||||
<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'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.yelp_review_link }
|
||||
@ -126,7 +126,7 @@ const CardBusinesses = ({
|
||||
|
||||
|
||||
<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'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.facebook_review_link }
|
||||
@ -138,7 +138,7 @@ const CardBusinesses = ({
|
||||
|
||||
|
||||
<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'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.delay_days }
|
||||
@ -150,7 +150,7 @@ const CardBusinesses = ({
|
||||
|
||||
|
||||
<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'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.email_subject_template }
|
||||
@ -162,7 +162,7 @@ const CardBusinesses = ({
|
||||
|
||||
|
||||
<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'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.email_body_template }
|
||||
@ -174,7 +174,7 @@ const CardBusinesses = ({
|
||||
|
||||
|
||||
<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'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ dataFormatter.booleanFormatter(item.is_active) }
|
||||
@ -186,7 +186,7 @@ const CardBusinesses = ({
|
||||
|
||||
|
||||
<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'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.stripe_account_reference }
|
||||
@ -198,7 +198,7 @@ const CardBusinesses = ({
|
||||
|
||||
|
||||
<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'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ dataFormatter.booleanFormatter(item.stripe_connected) }
|
||||
@ -210,7 +210,7 @@ const CardBusinesses = ({
|
||||
|
||||
|
||||
<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'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ dataFormatter.dateTimeFormatter(item.stripe_connected_at) }
|
||||
@ -222,7 +222,7 @@ const CardBusinesses = ({
|
||||
|
||||
|
||||
<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'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.default_review_platform }
|
||||
@ -234,7 +234,7 @@ const CardBusinesses = ({
|
||||
|
||||
|
||||
<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'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.custom_review_link }
|
||||
|
||||
@ -56,7 +56,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
||||
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -64,7 +64,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
||||
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -72,7 +72,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
||||
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -80,7 +80,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
||||
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -88,7 +88,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
||||
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -96,7 +96,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
||||
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -104,7 +104,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
||||
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -112,7 +112,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
||||
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -120,7 +120,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
||||
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -128,7 +128,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
||||
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -136,7 +136,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
||||
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -144,7 +144,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
||||
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -152,7 +152,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
|
||||
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
@ -65,7 +65,7 @@ export const loadColumns = async (
|
||||
|
||||
{
|
||||
field: 'name',
|
||||
headerName: 'BusinessName',
|
||||
headerName: 'Business name',
|
||||
flex: 1,
|
||||
minWidth: 120,
|
||||
filterable: false,
|
||||
@ -80,7 +80,7 @@ export const loadColumns = async (
|
||||
|
||||
{
|
||||
field: 'google_review_link',
|
||||
headerName: 'GoogleReviewLink',
|
||||
headerName: 'Google review link',
|
||||
flex: 1,
|
||||
minWidth: 120,
|
||||
filterable: false,
|
||||
@ -95,7 +95,7 @@ export const loadColumns = async (
|
||||
|
||||
{
|
||||
field: 'yelp_review_link',
|
||||
headerName: 'YelpReviewLink',
|
||||
headerName: 'Yelp review link',
|
||||
flex: 1,
|
||||
minWidth: 120,
|
||||
filterable: false,
|
||||
@ -110,7 +110,7 @@ export const loadColumns = async (
|
||||
|
||||
{
|
||||
field: 'facebook_review_link',
|
||||
headerName: 'FacebookReviewLink',
|
||||
headerName: 'Facebook review link',
|
||||
flex: 1,
|
||||
minWidth: 120,
|
||||
filterable: false,
|
||||
@ -125,7 +125,7 @@ export const loadColumns = async (
|
||||
|
||||
{
|
||||
field: 'delay_days',
|
||||
headerName: 'DelayDays',
|
||||
headerName: 'Review delay days',
|
||||
flex: 1,
|
||||
minWidth: 120,
|
||||
filterable: false,
|
||||
@ -141,7 +141,7 @@ export const loadColumns = async (
|
||||
|
||||
{
|
||||
field: 'email_subject_template',
|
||||
headerName: 'EmailSubjectTemplate',
|
||||
headerName: 'Email subject template',
|
||||
flex: 1,
|
||||
minWidth: 120,
|
||||
filterable: false,
|
||||
@ -156,7 +156,7 @@ export const loadColumns = async (
|
||||
|
||||
{
|
||||
field: 'email_body_template',
|
||||
headerName: 'EmailBodyTemplate',
|
||||
headerName: 'Email body template',
|
||||
flex: 1,
|
||||
minWidth: 120,
|
||||
filterable: false,
|
||||
@ -171,7 +171,7 @@ export const loadColumns = async (
|
||||
|
||||
{
|
||||
field: 'is_active',
|
||||
headerName: 'IsActive',
|
||||
headerName: 'Active',
|
||||
flex: 1,
|
||||
minWidth: 120,
|
||||
filterable: false,
|
||||
@ -187,7 +187,7 @@ export const loadColumns = async (
|
||||
|
||||
{
|
||||
field: 'stripe_account_reference',
|
||||
headerName: 'StripeAccountReference',
|
||||
headerName: 'Stripe account reference',
|
||||
flex: 1,
|
||||
minWidth: 120,
|
||||
filterable: false,
|
||||
@ -202,7 +202,7 @@ export const loadColumns = async (
|
||||
|
||||
{
|
||||
field: 'stripe_connected',
|
||||
headerName: 'StripeConnected',
|
||||
headerName: 'Stripe connected',
|
||||
flex: 1,
|
||||
minWidth: 120,
|
||||
filterable: false,
|
||||
@ -218,7 +218,7 @@ export const loadColumns = async (
|
||||
|
||||
{
|
||||
field: 'stripe_connected_at',
|
||||
headerName: 'StripeConnectedAt',
|
||||
headerName: 'Stripe connected at',
|
||||
flex: 1,
|
||||
minWidth: 120,
|
||||
filterable: false,
|
||||
@ -236,7 +236,7 @@ export const loadColumns = async (
|
||||
|
||||
{
|
||||
field: 'default_review_platform',
|
||||
headerName: 'DefaultReviewPlatform',
|
||||
headerName: 'Default review platform',
|
||||
flex: 1,
|
||||
minWidth: 120,
|
||||
filterable: false,
|
||||
@ -251,7 +251,7 @@ export const loadColumns = async (
|
||||
|
||||
{
|
||||
field: 'custom_review_link',
|
||||
headerName: 'CustomReviewLink',
|
||||
headerName: 'Custom review link',
|
||||
flex: 1,
|
||||
minWidth: 120,
|
||||
filterable: false,
|
||||
|
||||
@ -11,6 +11,7 @@ import { setDarkMode } from '../stores/styleSlice'
|
||||
import { logoutUser } from '../stores/authSlice'
|
||||
import { useRouter } from 'next/router';
|
||||
import ClickOutside from "./ClickOutside";
|
||||
import { getPortalLabel } from '../helpers/portalRoles';
|
||||
|
||||
type Props = {
|
||||
item: MenuNavBarItem
|
||||
@ -29,7 +30,9 @@ export default function NavBarItem({ item }: Props) {
|
||||
|
||||
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)
|
||||
|
||||
@ -46,7 +49,7 @@ export default function NavBarItem({ item }: Props) {
|
||||
item.isDesktopNoLabel ? 'lg:w-16 lg:justify-center' : '',
|
||||
].join(' ')
|
||||
|
||||
const itemLabel = item.isCurrentUser ? userName : item.label
|
||||
const itemLabel = item.isCurrentUser ? userDisplayName : item.label
|
||||
|
||||
const handleMenuClick = () => {
|
||||
if (item.menu) {
|
||||
@ -91,7 +94,12 @@ export default function NavBarItem({ item }: Props) {
|
||||
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>
|
||||
{item.isCurrentUser && <UserAvatarCurrentUser className="w-6 h-6 mr-3 inline-flex" />}
|
||||
{item.menu && (
|
||||
|
||||
@ -11,6 +11,7 @@ import React, { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
import BaseButton from '../BaseButton';
|
||||
import CardBox from '../CardBox';
|
||||
import FormField from '../FormField';
|
||||
import { getBusinessProfileUsageLabel } from '../../helpers/businessPlanLabels';
|
||||
|
||||
export interface ProviderConnector {
|
||||
key: 'stripe' | 'square' | 'paypal' | 'shopify' | 'woocommerce' | string;
|
||||
@ -858,8 +859,8 @@ export default function PaymentProviderConnectors({
|
||||
<p className='mt-2 text-sm leading-6'>
|
||||
{isConnectorSubscriptionInactive
|
||||
? '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.`}
|
||||
{' '}Updating an already connected provider may still work, but new providers or new businesses can be blocked.
|
||||
: `${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 business profiles can be blocked.
|
||||
</p>
|
||||
<BaseButton
|
||||
href='/subscription'
|
||||
|
||||
@ -3,6 +3,8 @@ import axios from 'axios'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import BaseButton from './BaseButton'
|
||||
import CardBox from './CardBox'
|
||||
import { useAppSelector } from '../stores/hooks'
|
||||
import { isInternalAdmin } from '../helpers/portalRoles'
|
||||
|
||||
type LimitKey = 'monthlyReviewRequests' | 'businesses' | 'teamMembers' | 'paymentConnectors'
|
||||
|
||||
@ -27,7 +29,7 @@ type Props = {
|
||||
|
||||
const defaultLabels: Record<LimitKey, string> = {
|
||||
monthlyReviewRequests: 'review requests this month',
|
||||
businesses: 'businesses/locations',
|
||||
businesses: 'business profiles',
|
||||
teamMembers: 'team members',
|
||||
paymentConnectors: 'connected payment providers',
|
||||
}
|
||||
@ -43,6 +45,7 @@ export default function SubscriptionLimitGate({
|
||||
className = 'mb-6',
|
||||
nearLimitPercent = 80,
|
||||
}: Props) {
|
||||
const { currentUser } = useAppSelector((state) => state.auth)
|
||||
const [status, setStatus] = useState<SubscriptionLimitStatus | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
@ -66,12 +69,20 @@ export default function SubscriptionLimitGate({
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentUser || isInternalAdmin(currentUser)) {
|
||||
setStatus(null)
|
||||
setError('')
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}
|
||||
|
||||
loadStatus()
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [])
|
||||
}, [currentUser])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
@ -89,7 +100,7 @@ export default function SubscriptionLimitGate({
|
||||
const used = Number(status.usage[limitKey]) || 0
|
||||
const limit = Number(status.limits[limitKey]) || 0
|
||||
const limitLabel = label || (limit === 1 && limitKey === 'businesses'
|
||||
? 'business/location'
|
||||
? 'business profile'
|
||||
: defaultLabels[limitKey])
|
||||
const percent = limit > 0 ? Math.round((used / limit) * 100) : 0
|
||||
const isInactive = !status.subscription.isActive
|
||||
|
||||
26
frontend/src/helpers/businessPlanLabels.ts
Normal file
26
frontend/src/helpers/businessPlanLabels.ts
Normal 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)}`
|
||||
}
|
||||
15
frontend/src/helpers/portalRoles.ts
Normal file
15
frontend/src/helpers/portalRoles.ts
Normal 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'
|
||||
}
|
||||
@ -1,18 +1,21 @@
|
||||
|
||||
export function hasPermission(user, permission_name: string | string[]) {
|
||||
if (!user?.app_role?.name) return false;
|
||||
export function hasPermission(user, permission_name?: string | string[]) {
|
||||
if (!user?.app_role?.name) return false
|
||||
if (!permission_name) {
|
||||
return true;
|
||||
return true
|
||||
}
|
||||
|
||||
if (user.app_role.name === 'Administrator') return true
|
||||
|
||||
const permissions = new Set<string>([
|
||||
...(user?.custom_permissions ?? []).map((p) => p.name),
|
||||
...(user?.app_role_permissions ?? []).map((p) => p.name),
|
||||
]);
|
||||
])
|
||||
|
||||
if (typeof permission_name === 'string') {
|
||||
return permissions.has(permission_name) || user.app_role.name === 'Administrator'
|
||||
} else {
|
||||
return permission_name.some((permission) => permissions.has(permission));
|
||||
return permissions.has(permission_name)
|
||||
}
|
||||
|
||||
return permission_name.some((permission) => permissions.has(permission))
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { ReactNode, useEffect, useState } from 'react'
|
||||
import React, { ReactNode, useEffect, useMemo, useState } from 'react'
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||
import menuAside from '../menuAside'
|
||||
import { getMenuAsideForUser } from '../menuAside'
|
||||
import menuNavBar from '../menuNavBar'
|
||||
import BaseIcon from '../components/BaseIcon'
|
||||
import NavBar from '../components/NavBar'
|
||||
@ -14,19 +14,23 @@ import { useRouter } from 'next/router'
|
||||
import {findMe, logoutUser} from "../stores/authSlice";
|
||||
|
||||
import {hasPermission} from "../helpers/userPermissions";
|
||||
import { getBusinessMenuLabel } from '../helpers/businessPlanLabels';
|
||||
import { getPortalLabel, isInternalAdmin } from '../helpers/portalRoles';
|
||||
|
||||
|
||||
type Props = {
|
||||
children: ReactNode
|
||||
|
||||
permission?: string
|
||||
portal?: 'admin' | 'customer'
|
||||
|
||||
}
|
||||
|
||||
export default function LayoutAuthenticated({
|
||||
children,
|
||||
|
||||
permission
|
||||
permission,
|
||||
portal
|
||||
|
||||
}: Props) {
|
||||
const dispatch = useAppDispatch()
|
||||
@ -62,12 +66,39 @@ export default function LayoutAuthenticated({
|
||||
|
||||
if (!hasPermission(currentUser, permission)) router.push('/error');
|
||||
}, [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 [isAsideMobileExpanded, setIsAsideMobileExpanded] = 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(() => {
|
||||
const handleRouteChangeStart = () => {
|
||||
@ -117,11 +148,11 @@ export default function LayoutAuthenticated({
|
||||
<AsideMenu
|
||||
isAsideMobileExpanded={isAsideMobileExpanded}
|
||||
isAsideLgActive={isAsideLgActive}
|
||||
menu={menuAside}
|
||||
menu={planAwareMenuAside}
|
||||
onAsideLgClose={() => setIsAsideLgActive(false)}
|
||||
/>
|
||||
{children}
|
||||
<FooterBar>Hand-crafted & Made with ❤️</FooterBar>
|
||||
<FooterBar>{portalLabel} · ReviewFlow</FooterBar>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,115 +1,193 @@
|
||||
import * as icon from '@mdi/js';
|
||||
import { MenuAsideItem } from './interfaces';
|
||||
import * as icon from '@mdi/js'
|
||||
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',
|
||||
icon: icon.mdiViewDashboardOutline,
|
||||
label: 'Dashboard',
|
||||
label: 'Workspace dashboard',
|
||||
},
|
||||
|
||||
{
|
||||
href: '/reviewflow',
|
||||
icon: icon.mdiStarOutline,
|
||||
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',
|
||||
label: 'Businesses',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon:
|
||||
'mdiStore' in icon
|
||||
? icon['mdiStore' as keyof typeof icon]
|
||||
: (icon.mdiTable ?? icon.mdiTable),
|
||||
icon: storeIcon,
|
||||
permissions: 'READ_BUSINESSES',
|
||||
},
|
||||
{
|
||||
href: '/customers/customers-list',
|
||||
label: 'Customers',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon:
|
||||
'mdiAccountMultiple' in icon
|
||||
? icon['mdiAccountMultiple' as keyof typeof icon]
|
||||
: (icon.mdiTable ?? icon.mdiTable),
|
||||
icon: accountMultipleIcon,
|
||||
permissions: 'READ_CUSTOMERS',
|
||||
},
|
||||
{
|
||||
href: '/transactions/transactions-list',
|
||||
label: 'Transactions',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon:
|
||||
'mdiCreditCardOutline' in icon
|
||||
? icon['mdiCreditCardOutline' as keyof typeof icon]
|
||||
: (icon.mdiTable ?? icon.mdiTable),
|
||||
icon: icon.mdiCreditCardOutline,
|
||||
permissions: 'READ_TRANSACTIONS',
|
||||
},
|
||||
{
|
||||
href: '/review_requests/review_requests-list',
|
||||
label: 'Review requests',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon:
|
||||
'mdiEmailFastOutline' in icon
|
||||
? icon['mdiEmailFastOutline' as keyof typeof icon]
|
||||
: (icon.mdiTable ?? icon.mdiTable),
|
||||
icon: emailFastIcon,
|
||||
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',
|
||||
label: 'Email delivery logs',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon:
|
||||
'mdiEmailCheckOutline' in icon
|
||||
? icon['mdiEmailCheckOutline' as keyof typeof icon]
|
||||
: (icon.mdiTable ?? icon.mdiTable),
|
||||
label: 'Email delivery',
|
||||
icon: emailCheckIcon,
|
||||
permissions: 'READ_EMAIL_DELIVERY_LOGS',
|
||||
},
|
||||
{
|
||||
href: '/cron_runs/cron_runs-list',
|
||||
label: 'Cron runs',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon:
|
||||
'mdiClockOutline' in icon
|
||||
? icon['mdiClockOutline' as keyof typeof icon]
|
||||
: (icon.mdiTable ?? icon.mdiTable),
|
||||
permissions: 'READ_CRON_RUNS',
|
||||
href: '/subscription',
|
||||
icon: icon.mdiCreditCardOutline,
|
||||
label: 'Subscription',
|
||||
},
|
||||
{
|
||||
href: '/profile',
|
||||
label: 'Profile',
|
||||
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
|
||||
|
||||
@ -469,10 +469,10 @@ const EditBusinesses = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Edit businesses')}</title>
|
||||
<title>{getPageTitle('Edit Business')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit businesses'} main>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='Edit Business' main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox>
|
||||
@ -550,11 +550,11 @@ const EditBusinesses = () => {
|
||||
|
||||
|
||||
<FormField
|
||||
label="BusinessName"
|
||||
label="Business name"
|
||||
>
|
||||
<Field
|
||||
name="name"
|
||||
placeholder="BusinessName"
|
||||
placeholder="Business name"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@ -587,11 +587,11 @@ const EditBusinesses = () => {
|
||||
|
||||
|
||||
<FormField
|
||||
label="GoogleReviewLink"
|
||||
label="Google review link"
|
||||
>
|
||||
<Field
|
||||
name="google_review_link"
|
||||
placeholder="GoogleReviewLink"
|
||||
placeholder="Google review link"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@ -624,11 +624,11 @@ const EditBusinesses = () => {
|
||||
|
||||
|
||||
<FormField
|
||||
label="YelpReviewLink"
|
||||
label="Yelp review link"
|
||||
>
|
||||
<Field
|
||||
name="yelp_review_link"
|
||||
placeholder="YelpReviewLink"
|
||||
placeholder="Yelp review link"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@ -661,11 +661,11 @@ const EditBusinesses = () => {
|
||||
|
||||
|
||||
<FormField
|
||||
label="FacebookReviewLink"
|
||||
label="Facebook review link"
|
||||
>
|
||||
<Field
|
||||
name="facebook_review_link"
|
||||
placeholder="FacebookReviewLink"
|
||||
placeholder="Facebook review link"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@ -704,12 +704,12 @@ const EditBusinesses = () => {
|
||||
|
||||
|
||||
<FormField
|
||||
label="DelayDays"
|
||||
label="Review delay days"
|
||||
>
|
||||
<Field
|
||||
type="number"
|
||||
name="delay_days"
|
||||
placeholder="DelayDays"
|
||||
placeholder="Review delay days"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@ -736,11 +736,11 @@ const EditBusinesses = () => {
|
||||
|
||||
|
||||
<FormField
|
||||
label="EmailSubjectTemplate"
|
||||
label="Email subject template"
|
||||
>
|
||||
<Field
|
||||
name="email_subject_template"
|
||||
placeholder="EmailSubjectTemplate"
|
||||
placeholder="Email subject template"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@ -776,7 +776,7 @@ const EditBusinesses = () => {
|
||||
|
||||
|
||||
|
||||
<FormField label='EmailBodyTemplate' hasTextareaHeight>
|
||||
<FormField label='Email body template' hasTextareaHeight>
|
||||
<Field
|
||||
name='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
|
||||
name='is_active'
|
||||
id='is_active'
|
||||
@ -845,11 +845,11 @@ const EditBusinesses = () => {
|
||||
|
||||
|
||||
<FormField
|
||||
label="StripeAccountReference"
|
||||
label="Stripe account reference"
|
||||
>
|
||||
<Field
|
||||
name="stripe_account_reference"
|
||||
placeholder="StripeAccountReference"
|
||||
placeholder="Stripe account reference"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@ -897,7 +897,7 @@ const EditBusinesses = () => {
|
||||
|
||||
|
||||
|
||||
<FormField label='StripeConnected' labelFor='stripe_connected'>
|
||||
<FormField label='Stripe connected' labelFor='stripe_connected'>
|
||||
<Field
|
||||
name='stripe_connected'
|
||||
id='stripe_connected'
|
||||
@ -928,7 +928,7 @@ const EditBusinesses = () => {
|
||||
|
||||
|
||||
<FormField
|
||||
label="StripeConnectedAt"
|
||||
label="Stripe connected at"
|
||||
>
|
||||
<DatePicker
|
||||
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">
|
||||
|
||||
<option value="google">google</option>
|
||||
@ -1003,11 +1003,11 @@ const EditBusinesses = () => {
|
||||
|
||||
|
||||
<FormField
|
||||
label="CustomReviewLink"
|
||||
label="Custom review link"
|
||||
>
|
||||
<Field
|
||||
name="custom_review_link"
|
||||
placeholder="CustomReviewLink"
|
||||
placeholder="Custom review link"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
|
||||
@ -466,10 +466,10 @@ const EditBusinessesPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Edit businesses')}</title>
|
||||
<title>{getPageTitle('Edit Business')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit businesses'} main>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='Edit Business' main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox>
|
||||
@ -547,11 +547,11 @@ const EditBusinessesPage = () => {
|
||||
|
||||
|
||||
<FormField
|
||||
label="BusinessName"
|
||||
label="Business name"
|
||||
>
|
||||
<Field
|
||||
name="name"
|
||||
placeholder="BusinessName"
|
||||
placeholder="Business name"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@ -584,11 +584,11 @@ const EditBusinessesPage = () => {
|
||||
|
||||
|
||||
<FormField
|
||||
label="GoogleReviewLink"
|
||||
label="Google review link"
|
||||
>
|
||||
<Field
|
||||
name="google_review_link"
|
||||
placeholder="GoogleReviewLink"
|
||||
placeholder="Google review link"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@ -621,11 +621,11 @@ const EditBusinessesPage = () => {
|
||||
|
||||
|
||||
<FormField
|
||||
label="YelpReviewLink"
|
||||
label="Yelp review link"
|
||||
>
|
||||
<Field
|
||||
name="yelp_review_link"
|
||||
placeholder="YelpReviewLink"
|
||||
placeholder="Yelp review link"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@ -658,11 +658,11 @@ const EditBusinessesPage = () => {
|
||||
|
||||
|
||||
<FormField
|
||||
label="FacebookReviewLink"
|
||||
label="Facebook review link"
|
||||
>
|
||||
<Field
|
||||
name="facebook_review_link"
|
||||
placeholder="FacebookReviewLink"
|
||||
placeholder="Facebook review link"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@ -701,12 +701,12 @@ const EditBusinessesPage = () => {
|
||||
|
||||
|
||||
<FormField
|
||||
label="DelayDays"
|
||||
label="Review delay days"
|
||||
>
|
||||
<Field
|
||||
type="number"
|
||||
name="delay_days"
|
||||
placeholder="DelayDays"
|
||||
placeholder="Review delay days"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@ -733,11 +733,11 @@ const EditBusinessesPage = () => {
|
||||
|
||||
|
||||
<FormField
|
||||
label="EmailSubjectTemplate"
|
||||
label="Email subject template"
|
||||
>
|
||||
<Field
|
||||
name="email_subject_template"
|
||||
placeholder="EmailSubjectTemplate"
|
||||
placeholder="Email subject template"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@ -773,7 +773,7 @@ const EditBusinessesPage = () => {
|
||||
|
||||
|
||||
|
||||
<FormField label='EmailBodyTemplate' hasTextareaHeight>
|
||||
<FormField label='Email body template' hasTextareaHeight>
|
||||
<Field
|
||||
name='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
|
||||
name='is_active'
|
||||
id='is_active'
|
||||
@ -842,11 +842,11 @@ const EditBusinessesPage = () => {
|
||||
|
||||
|
||||
<FormField
|
||||
label="StripeAccountReference"
|
||||
label="Stripe account reference"
|
||||
>
|
||||
<Field
|
||||
name="stripe_account_reference"
|
||||
placeholder="StripeAccountReference"
|
||||
placeholder="Stripe account reference"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@ -894,7 +894,7 @@ const EditBusinessesPage = () => {
|
||||
|
||||
|
||||
|
||||
<FormField label='StripeConnected' labelFor='stripe_connected'>
|
||||
<FormField label='Stripe connected' labelFor='stripe_connected'>
|
||||
<Field
|
||||
name='stripe_connected'
|
||||
id='stripe_connected'
|
||||
@ -925,7 +925,7 @@ const EditBusinessesPage = () => {
|
||||
|
||||
|
||||
<FormField
|
||||
label="StripeConnectedAt"
|
||||
label="Stripe connected at"
|
||||
>
|
||||
<DatePicker
|
||||
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">
|
||||
|
||||
<option value="google">google</option>
|
||||
@ -1000,11 +1000,11 @@ const EditBusinessesPage = () => {
|
||||
|
||||
|
||||
<FormField
|
||||
label="CustomReviewLink"
|
||||
label="Custom review link"
|
||||
>
|
||||
<Field
|
||||
name="custom_review_link"
|
||||
placeholder="CustomReviewLink"
|
||||
placeholder="Custom review link"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
|
||||
@ -14,10 +14,13 @@ import Link from "next/link";
|
||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||
import CardBoxModal from "../../components/CardBoxModal";
|
||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||
import SubscriptionLimitGate from '../../components/SubscriptionLimitGate';
|
||||
import {setRefetch, uploadCsv} from '../../stores/businesses/businessesSlice';
|
||||
|
||||
|
||||
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 [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'},
|
||||
{label: 'DelayDays', title: 'delay_days', number: 'true'},
|
||||
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: '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: '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 isAdminPortal = isInternalAdmin(currentUser);
|
||||
const businessPageTitle = isAdminPortal ? 'Business profiles' : getBusinessMenuLabel(currentUser?.subscriptionPlanId);
|
||||
|
||||
|
||||
const addFilter = () => {
|
||||
@ -90,15 +95,27 @@ const BusinessesTablesPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Businesses')}</title>
|
||||
<title>{getPageTitle(businessPageTitle)}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Businesses" main>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={businessPageTitle} main>
|
||||
{''}
|
||||
</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
|
||||
className={'mr-3'}
|
||||
|
||||
@ -272,15 +272,15 @@ const BusinessesNew = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('New Item')}</title>
|
||||
<title>{getPageTitle('Add Business')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='Add Business' main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<SubscriptionLimitGate
|
||||
limitKey='businesses'
|
||||
actionLabel='Adding another business/location'
|
||||
actionLabel='Adding another business profile'
|
||||
/>
|
||||
<CardBox>
|
||||
<Formik
|
||||
@ -326,11 +326,11 @@ const BusinessesNew = () => {
|
||||
|
||||
|
||||
<FormField
|
||||
label="BusinessName"
|
||||
label="Business name"
|
||||
>
|
||||
<Field
|
||||
name="name"
|
||||
placeholder="BusinessName"
|
||||
placeholder="Business name"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@ -361,11 +361,11 @@ const BusinessesNew = () => {
|
||||
|
||||
|
||||
<FormField
|
||||
label="GoogleReviewLink"
|
||||
label="Google review link"
|
||||
>
|
||||
<Field
|
||||
name="google_review_link"
|
||||
placeholder="GoogleReviewLink"
|
||||
placeholder="Google review link"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@ -396,11 +396,11 @@ const BusinessesNew = () => {
|
||||
|
||||
|
||||
<FormField
|
||||
label="YelpReviewLink"
|
||||
label="Yelp review link"
|
||||
>
|
||||
<Field
|
||||
name="yelp_review_link"
|
||||
placeholder="YelpReviewLink"
|
||||
placeholder="Yelp review link"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@ -431,11 +431,11 @@ const BusinessesNew = () => {
|
||||
|
||||
|
||||
<FormField
|
||||
label="FacebookReviewLink"
|
||||
label="Facebook review link"
|
||||
>
|
||||
<Field
|
||||
name="facebook_review_link"
|
||||
placeholder="FacebookReviewLink"
|
||||
placeholder="Facebook review link"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@ -472,12 +472,12 @@ const BusinessesNew = () => {
|
||||
|
||||
|
||||
<FormField
|
||||
label="DelayDays"
|
||||
label="Review delay days"
|
||||
>
|
||||
<Field
|
||||
type="number"
|
||||
name="delay_days"
|
||||
placeholder="DelayDays"
|
||||
placeholder="Review delay days"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@ -502,11 +502,11 @@ const BusinessesNew = () => {
|
||||
|
||||
|
||||
<FormField
|
||||
label="EmailSubjectTemplate"
|
||||
label="Email subject template"
|
||||
>
|
||||
<Field
|
||||
name="email_subject_template"
|
||||
placeholder="EmailSubjectTemplate"
|
||||
placeholder="Email subject template"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@ -540,7 +540,7 @@ const BusinessesNew = () => {
|
||||
|
||||
|
||||
|
||||
<FormField label='EmailBodyTemplate' hasTextareaHeight>
|
||||
<FormField label='Email body template' hasTextareaHeight>
|
||||
<Field
|
||||
name='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
|
||||
name='is_active'
|
||||
id='is_active'
|
||||
@ -605,11 +605,11 @@ const BusinessesNew = () => {
|
||||
|
||||
|
||||
<FormField
|
||||
label="StripeAccountReference"
|
||||
label="Stripe account reference"
|
||||
>
|
||||
<Field
|
||||
name="stripe_account_reference"
|
||||
placeholder="StripeAccountReference"
|
||||
placeholder="Stripe account reference"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@ -655,7 +655,7 @@ const BusinessesNew = () => {
|
||||
|
||||
|
||||
|
||||
<FormField label='StripeConnected' labelFor='stripe_connected'>
|
||||
<FormField label='Stripe connected' labelFor='stripe_connected'>
|
||||
<Field
|
||||
name='stripe_connected'
|
||||
id='stripe_connected'
|
||||
@ -684,12 +684,12 @@ const BusinessesNew = () => {
|
||||
|
||||
|
||||
<FormField
|
||||
label="StripeConnectedAt"
|
||||
label="Stripe connected at"
|
||||
>
|
||||
<Field
|
||||
type="datetime-local"
|
||||
name="stripe_connected_at"
|
||||
placeholder="StripeConnectedAt"
|
||||
placeholder="Stripe connected at"
|
||||
/>
|
||||
</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">
|
||||
|
||||
<option value="google">google</option>
|
||||
@ -750,11 +750,11 @@ const BusinessesNew = () => {
|
||||
|
||||
|
||||
<FormField
|
||||
label="CustomReviewLink"
|
||||
label="Custom review link"
|
||||
>
|
||||
<Field
|
||||
name="custom_review_link"
|
||||
placeholder="CustomReviewLink"
|
||||
placeholder="Custom review link"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
|
||||
@ -14,10 +14,13 @@ import Link from "next/link";
|
||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||
import CardBoxModal from "../../components/CardBoxModal";
|
||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||
import SubscriptionLimitGate from '../../components/SubscriptionLimitGate';
|
||||
import {setRefetch, uploadCsv} from '../../stores/businesses/businessesSlice';
|
||||
|
||||
|
||||
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 [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'},
|
||||
{label: 'DelayDays', title: 'delay_days', number: 'true'},
|
||||
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: '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: '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 isAdminPortal = isInternalAdmin(currentUser);
|
||||
const businessPageTitle = isAdminPortal ? 'Business profiles' : getBusinessMenuLabel(currentUser?.subscriptionPlanId);
|
||||
|
||||
|
||||
const addFilter = () => {
|
||||
@ -90,15 +95,27 @@ const BusinessesTablesPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Businesses')}</title>
|
||||
<title>{getPageTitle(businessPageTitle)}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Businesses" main>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={businessPageTitle} main>
|
||||
{''}
|
||||
</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'>
|
||||
|
||||
{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
|
||||
className={'mr-3'}
|
||||
|
||||
@ -29,11 +29,6 @@ const BusinessesView = () => {
|
||||
|
||||
const { id } = router.query;
|
||||
|
||||
function removeLastCharacter(str) {
|
||||
console.log(str,`str`)
|
||||
return str.slice(0, -1);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetch({ id }));
|
||||
}, [dispatch, id]);
|
||||
@ -42,10 +37,10 @@ const BusinessesView = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('View businesses')}</title>
|
||||
<title>{getPageTitle('View Business')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={removeLastCharacter('View businesses')} main>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='View Business' main>
|
||||
<BaseButton
|
||||
color='info'
|
||||
label='Edit'
|
||||
@ -113,7 +108,7 @@ const BusinessesView = () => {
|
||||
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -145,7 +140,7 @@ const BusinessesView = () => {
|
||||
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -177,7 +172,7 @@ const BusinessesView = () => {
|
||||
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -209,7 +204,7 @@ const BusinessesView = () => {
|
||||
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -247,7 +242,7 @@ const BusinessesView = () => {
|
||||
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -273,7 +268,7 @@ const BusinessesView = () => {
|
||||
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -309,7 +304,7 @@ const BusinessesView = () => {
|
||||
|
||||
|
||||
<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
|
||||
? <p dangerouslySetInnerHTML={{__html: businesses.email_body_template}}/>
|
||||
: <p>No data</p>
|
||||
@ -355,7 +350,7 @@ const BusinessesView = () => {
|
||||
|
||||
|
||||
|
||||
<FormField label='IsActive'>
|
||||
<FormField label='Active'>
|
||||
<SwitchField
|
||||
field={{name: 'is_active', value: businesses?.is_active}}
|
||||
form={{setFieldValue: () => null}}
|
||||
@ -375,7 +370,7 @@ const BusinessesView = () => {
|
||||
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -422,7 +417,7 @@ const BusinessesView = () => {
|
||||
|
||||
|
||||
|
||||
<FormField label='StripeConnected'>
|
||||
<FormField label='Stripe connected'>
|
||||
<SwitchField
|
||||
field={{name: 'stripe_connected', value: businesses?.stripe_connected}}
|
||||
form={{setFieldValue: () => null}}
|
||||
@ -451,7 +446,7 @@ const BusinessesView = () => {
|
||||
|
||||
|
||||
|
||||
<FormField label='StripeConnectedAt'>
|
||||
<FormField label='Stripe connected at'>
|
||||
{businesses.stripe_connected_at ? <DatePicker
|
||||
dateFormat="yyyy-MM-dd hh:mm"
|
||||
showTimeSelect
|
||||
@ -461,7 +456,7 @@ const BusinessesView = () => {
|
||||
) : null
|
||||
}
|
||||
disabled
|
||||
/> : <p>No StripeConnectedAt</p>}
|
||||
/> : <p>No Stripe connection date</p>}
|
||||
</FormField>
|
||||
|
||||
|
||||
@ -496,7 +491,7 @@ const BusinessesView = () => {
|
||||
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -514,7 +509,7 @@ const BusinessesView = () => {
|
||||
|
||||
|
||||
<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>
|
||||
</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
|
||||
className='mb-6 border border-gray-300 rounded overflow-hidden'
|
||||
hasTable
|
||||
|
||||
@ -60,5 +60,5 @@ export default function ConnectPage() {
|
||||
}
|
||||
|
||||
ConnectPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||
return <LayoutAuthenticated portal='customer'>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
|
||||
@ -645,6 +645,7 @@ const EditCron_runs = () => {
|
||||
EditCron_runs.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'UPDATE_CRON_RUNS'}
|
||||
|
||||
|
||||
@ -642,6 +642,7 @@ const EditCron_runsPage = () => {
|
||||
EditCron_runsPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'UPDATE_CRON_RUNS'}
|
||||
|
||||
|
||||
@ -154,6 +154,7 @@ const Cron_runsTablesPage = () => {
|
||||
Cron_runsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'READ_CRON_RUNS'}
|
||||
|
||||
|
||||
@ -504,6 +504,7 @@ const Cron_runsNew = () => {
|
||||
Cron_runsNew.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'CREATE_CRON_RUNS'}
|
||||
|
||||
|
||||
@ -152,6 +152,7 @@ const Cron_runsTablesPage = () => {
|
||||
Cron_runsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'READ_CRON_RUNS'}
|
||||
|
||||
|
||||
@ -354,6 +354,7 @@ const Cron_runsView = () => {
|
||||
Cron_runsView.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'READ_CRON_RUNS'}
|
||||
|
||||
|
||||
@ -1,431 +1,546 @@
|
||||
import * as icon from '@mdi/js';
|
||||
import * as icon from '@mdi/js'
|
||||
import Head from 'next/head'
|
||||
import React from 'react'
|
||||
import axios from 'axios';
|
||||
import axios from 'axios'
|
||||
import type { ReactElement } from 'react'
|
||||
import Link from 'next/link'
|
||||
import LayoutAuthenticated from '../layouts/Authenticated'
|
||||
import SectionMain from '../components/SectionMain'
|
||||
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 Link from "next/link";
|
||||
|
||||
import { hasPermission } from "../helpers/userPermissions";
|
||||
import { fetchWidgets } from '../stores/roles/rolesSlice';
|
||||
import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator';
|
||||
import { SmartWidget } from '../components/SmartWidget/SmartWidget';
|
||||
import { hasPermission } from '../helpers/userPermissions'
|
||||
import { getBusinessMenuLabel } from '../helpers/businessPlanLabels'
|
||||
import { getPortalLabel, isInternalAdmin } from '../helpers/portalRoles'
|
||||
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 dispatch = useAppDispatch();
|
||||
const iconsColor = useAppSelector((state) => state.style.iconsColor);
|
||||
const corners = useAppSelector((state) => state.style.corners);
|
||||
const cardsStyle = useAppSelector((state) => state.style.cardsStyle);
|
||||
const dispatch = useAppDispatch()
|
||||
const iconsColor = useAppSelector((state) => state.style.iconsColor)
|
||||
const corners = useAppSelector((state) => state.style.corners)
|
||||
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 [users, setUsers] = React.useState(loadingMessage);
|
||||
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 loadData = React.useCallback(async () => {
|
||||
if (!currentUser) return
|
||||
|
||||
|
||||
const [widgetsRole, setWidgetsRole] = React.useState({
|
||||
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 = entityKeys.map(async (key) => {
|
||||
const config = entityConfig[key]
|
||||
|
||||
const requests = entities.map((entity, index) => {
|
||||
|
||||
if(hasPermission(currentUser, `READ_${entity.toUpperCase()}`)) {
|
||||
return axios.get(`/${entity.toLowerCase()}/count`);
|
||||
} else {
|
||||
fns[index](null);
|
||||
return Promise.resolve({data: {count: null}});
|
||||
}
|
||||
|
||||
});
|
||||
if (!hasPermission(currentUser, config.permission)) {
|
||||
return { key, count: null }
|
||||
}
|
||||
|
||||
Promise.allSettled(requests).then((results) => {
|
||||
results.forEach((result, i) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
fns[i](result.value.data.count);
|
||||
} else {
|
||||
fns[i](result.reason.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function getWidgets(roleId) {
|
||||
await dispatch(fetchWidgets(roleId));
|
||||
}
|
||||
React.useEffect(() => {
|
||||
if (!currentUser) return;
|
||||
loadData().then();
|
||||
setWidgetsRole({ role: { value: currentUser?.app_role?.id, label: currentUser?.app_role?.name } });
|
||||
}, [currentUser]);
|
||||
const response = await axios.get(`/${config.endpoint}/count`)
|
||||
|
||||
return { key, count: response.data.count as CountValue }
|
||||
})
|
||||
|
||||
const results = await Promise.allSettled(requests)
|
||||
|
||||
setCounts((previousCounts) => {
|
||||
const nextCounts = { ...previousCounts }
|
||||
|
||||
results.forEach((result, index) => {
|
||||
const key = entityKeys[index]
|
||||
|
||||
if (result.status === 'fulfilled') {
|
||||
nextCounts[result.value.key] = result.value.count
|
||||
} else {
|
||||
console.error(`Failed to load ${key} dashboard count:`, result.reason)
|
||||
nextCounts[key] = 'Error'
|
||||
}
|
||||
})
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Head>
|
||||
<title>
|
||||
{getPageTitle('Overview')}
|
||||
</title>
|
||||
<title>{getPageTitle(title)}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton
|
||||
icon={icon.mdiChartTimelineVariant}
|
||||
title='Overview'
|
||||
main>
|
||||
<SectionTitleLineWithButton icon={sectionIcon} title={title} main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
{hasPermission(currentUser, 'CREATE_ROLES') && <WidgetCreator
|
||||
|
||||
<PortalIntroCard currentUser={currentUser} adminPortal={adminPortal} />
|
||||
|
||||
{showCustomerWidgets && (
|
||||
<WidgetCreator
|
||||
currentUser={currentUser}
|
||||
isFetchingQuery={isFetchingQuery}
|
||||
setWidgetsRole={setWidgetsRole}
|
||||
widgetsRole={widgetsRole}
|
||||
/>}
|
||||
{!!rolesWidgets.length &&
|
||||
hasPermission(currentUser, 'CREATE_ROLES') && (
|
||||
<p className=' text-gray-500 dark:text-gray-400 mb-4'>
|
||||
{`${widgetsRole?.role?.label || 'Users'}'s widgets`}
|
||||
</p>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{!!rolesWidgets.length && showCustomerWidgets && (
|
||||
<p className='mb-4 text-gray-500 dark:text-gray-400'>
|
||||
{`${widgetsRole?.role?.label || 'Users'}'s widgets`}
|
||||
</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) && (
|
||||
<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
|
||||
className={`${iconsColor} animate-spin mr-5`}
|
||||
w='w-16'
|
||||
h='h-16'
|
||||
size={48}
|
||||
path={icon.mdiLoading}
|
||||
/>{' '}
|
||||
Loading widgets...
|
||||
</div>
|
||||
<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
|
||||
className={`${iconsColor} animate-spin mr-5`}
|
||||
w='w-16'
|
||||
h='h-16'
|
||||
size={48}
|
||||
path={icon.mdiLoading}
|
||||
/>{' '}
|
||||
Loading widgets...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ rolesWidgets &&
|
||||
rolesWidgets.map((widget) => (
|
||||
<SmartWidget
|
||||
key={widget.id}
|
||||
userId={currentUser?.id}
|
||||
widget={widget}
|
||||
roleId={widgetsRole?.role?.value || ''}
|
||||
admin={hasPermission(currentUser, 'CREATE_ROLES')}
|
||||
/>
|
||||
{rolesWidgets.map((widget) => (
|
||||
<SmartWidget
|
||||
key={widget.id}
|
||||
userId={currentUser?.id}
|
||||
widget={widget}
|
||||
roleId={widgetsRole?.role?.value || ''}
|
||||
admin={false}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
|
||||
{!!rolesWidgets.length && <hr className='my-6 ' />}
|
||||
|
||||
<div id="dashboard" className='grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6'>
|
||||
|
||||
|
||||
{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 className='grid grid-cols-1 gap-6 lg:grid-cols-3'>
|
||||
{actionGroups.map((group) => (
|
||||
<ActionGroupCard key={group.title} group={group} currentUser={currentUser} />
|
||||
))}
|
||||
</div>
|
||||
</SectionMain>
|
||||
</>
|
||||
|
||||
@ -7,6 +7,7 @@ import CardBox from '../components/CardBox';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { getPageTitle } from '../config';
|
||||
import { subscriptionPlans, trialDays } from '../subscriptionPlans';
|
||||
import { getBusinessProfileNoun } from '../helpers/businessPlanLabels';
|
||||
|
||||
const metrics = [
|
||||
['7 days', 'default review delay'],
|
||||
@ -24,7 +25,7 @@ const features = [
|
||||
'Business review links and templates',
|
||||
'Webhook-created customers and transactions',
|
||||
'Readable queue with message preview',
|
||||
'Admin CRUD and API docs still available',
|
||||
'Internal admin controls stay separate from customer workspaces',
|
||||
];
|
||||
|
||||
export default function Starter() {
|
||||
@ -49,7 +50,7 @@ export default function Starter() {
|
||||
<nav className="flex items-center gap-3">
|
||||
<BaseButton href="/#pricing" label="Pricing" 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>
|
||||
</div>
|
||||
</header>
|
||||
@ -71,7 +72,7 @@ export default function Starter() {
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<BaseButton href="/reviewflow" icon={mdiStarCircleOutline} label="Open Review Flow" color="info" className="shadow-xl shadow-indigo-600/20" />
|
||||
<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 className="mt-10 grid max-w-2xl gap-3 sm:grid-cols-3">
|
||||
{metrics.map(([value, label]) => (
|
||||
@ -181,7 +182,7 @@ export default function Starter() {
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<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">
|
||||
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>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
|
||||
@ -46,8 +46,8 @@ export default function Login() {
|
||||
|
||||
const appHighlights = [
|
||||
'Automated review requests after payments, jobs, or service milestones.',
|
||||
'Customer, business, transaction, and delivery follow-up data in one admin workspace.',
|
||||
'Dashboards, CRM records, payment events, email logs, and admin controls already built in.',
|
||||
'Customer, business, transaction, and delivery follow-up data in one customer workspace.',
|
||||
'Dashboards, CRM records, payment events, email logs, and separate internal admin controls already built in.',
|
||||
];
|
||||
|
||||
const competitorAdvantages = [
|
||||
@ -87,7 +87,7 @@ export default function Login() {
|
||||
title: 'Starter limits',
|
||||
features: [
|
||||
'250 review requests per month.',
|
||||
'1 business or location.',
|
||||
'1 business profile.',
|
||||
'2 team members.',
|
||||
'Stripe, Square, PayPal, Shopify, and WooCommerce webhook intake.',
|
||||
],
|
||||
@ -104,7 +104,7 @@ export default function Login() {
|
||||
title: 'Everything in Starter',
|
||||
features: [
|
||||
'2,500 review requests per month.',
|
||||
'10 businesses or locations.',
|
||||
'10 business profiles.',
|
||||
'10 team members.',
|
||||
'Priority support and advanced reporting.',
|
||||
],
|
||||
@ -222,13 +222,13 @@ export default function Login() {
|
||||
data-password="fc6e39e3"
|
||||
onClick={(e) => setLogin(e.target)}>admin@flatlogic.com</code>{' / '}
|
||||
<code className={`${textColor}`}>fc6e39e3</code>{' / '}
|
||||
to login as Admin</p>
|
||||
to login as Internal Admin</p>
|
||||
<p>Use <code
|
||||
className={`cursor-pointer ${textColor} `}
|
||||
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>{' / '}
|
||||
to login as User</p>
|
||||
to login as Customer Owner</p>
|
||||
</div>
|
||||
<div>
|
||||
<BaseIcon
|
||||
@ -316,7 +316,7 @@ export default function Login() {
|
||||
Review Flow helps logistics and transportation businesses turn completed jobs, payments,
|
||||
and customer interactions into organized review requests. Your team can manage customer
|
||||
records, monitor follow-up, and keep reputation-building work moving from one secure
|
||||
admin panel.
|
||||
workspace.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@ -175,6 +175,7 @@ const EditPermissions = () => {
|
||||
EditPermissions.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'UPDATE_PERMISSIONS'}
|
||||
|
||||
|
||||
@ -172,6 +172,7 @@ const EditPermissionsPage = () => {
|
||||
EditPermissionsPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'UPDATE_PERMISSIONS'}
|
||||
|
||||
|
||||
@ -150,6 +150,7 @@ const PermissionsTablesPage = () => {
|
||||
PermissionsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'READ_PERMISSIONS'}
|
||||
|
||||
|
||||
@ -131,6 +131,7 @@ const PermissionsNew = () => {
|
||||
PermissionsNew.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'CREATE_PERMISSIONS'}
|
||||
|
||||
|
||||
@ -148,6 +148,7 @@ const PermissionsTablesPage = () => {
|
||||
PermissionsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'READ_PERMISSIONS'}
|
||||
|
||||
|
||||
@ -115,6 +115,7 @@ const PermissionsView = () => {
|
||||
PermissionsView.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'READ_PERMISSIONS'}
|
||||
|
||||
|
||||
@ -27,6 +27,7 @@ import SectionMain from '../components/SectionMain';
|
||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
import { getPageTitle } from '../config';
|
||||
import { getBusinessProfileLimitLabel } from '../helpers/businessPlanLabels';
|
||||
|
||||
interface ReviewBusiness {
|
||||
id?: string;
|
||||
@ -412,7 +413,7 @@ export default function ReviewFlowWorkspace() {
|
||||
currentSubscription.trialDaysLeft !== undefined
|
||||
? `${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>
|
||||
</div>
|
||||
<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.
|
||||
</h3>
|
||||
<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>
|
||||
<BaseButton
|
||||
href='/subscription'
|
||||
@ -896,5 +897,5 @@ export default function ReviewFlowWorkspace() {
|
||||
}
|
||||
|
||||
ReviewFlowWorkspace.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||
return <LayoutAuthenticated portal='customer'>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
|
||||
@ -264,6 +264,7 @@ const EditRoles = () => {
|
||||
EditRoles.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'UPDATE_ROLES'}
|
||||
|
||||
|
||||
@ -261,6 +261,7 @@ const EditRolesPage = () => {
|
||||
EditRolesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'UPDATE_ROLES'}
|
||||
|
||||
|
||||
@ -150,6 +150,7 @@ const RolesTablesPage = () => {
|
||||
RolesTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'READ_ROLES'}
|
||||
|
||||
|
||||
@ -183,6 +183,7 @@ const RolesNew = () => {
|
||||
RolesNew.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'CREATE_ROLES'}
|
||||
|
||||
|
||||
@ -148,6 +148,7 @@ const RolesTablesPage = () => {
|
||||
RolesTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'READ_ROLES'}
|
||||
|
||||
|
||||
@ -292,6 +292,7 @@ const RolesView = () => {
|
||||
RolesView.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'READ_ROLES'}
|
||||
|
||||
|
||||
@ -665,6 +665,7 @@ const EditStripe_events = () => {
|
||||
EditStripe_events.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'UPDATE_STRIPE_EVENTS'}
|
||||
|
||||
|
||||
@ -662,6 +662,7 @@ const EditStripe_eventsPage = () => {
|
||||
EditStripe_eventsPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'UPDATE_STRIPE_EVENTS'}
|
||||
|
||||
|
||||
@ -154,6 +154,7 @@ const Stripe_eventsTablesPage = () => {
|
||||
Stripe_eventsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'READ_STRIPE_EVENTS'}
|
||||
|
||||
|
||||
@ -482,6 +482,7 @@ const Stripe_eventsNew = () => {
|
||||
Stripe_eventsNew.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'CREATE_STRIPE_EVENTS'}
|
||||
|
||||
|
||||
@ -156,6 +156,7 @@ const Stripe_eventsTablesPage = () => {
|
||||
Stripe_eventsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'READ_STRIPE_EVENTS'}
|
||||
|
||||
|
||||
@ -380,6 +380,7 @@ const Stripe_eventsView = () => {
|
||||
Stripe_eventsView.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'READ_STRIPE_EVENTS'}
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@ import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton
|
||||
import LayoutAuthenticated from '../layouts/Authenticated'
|
||||
import { getPageTitle } from '../config'
|
||||
import { SubscriptionPlan } from '../subscriptionPlans'
|
||||
import { getBusinessProfileNoun, getBusinessProfileUsageLabel } from '../helpers/businessPlanLabels'
|
||||
|
||||
type SubscriptionStatusResponse = {
|
||||
subscription: {
|
||||
@ -57,7 +58,7 @@ const usageLabels: Array<{
|
||||
label: string
|
||||
}> = [
|
||||
{ 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: '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'>
|
||||
<p className='font-black text-slate-900 dark:text-white'>{item.label}</p>
|
||||
<p className={usageTextClass}>
|
||||
{formatLimit(used)} / {formatLimit(limit)}
|
||||
{item.limitKey === 'businesses'
|
||||
? getBusinessProfileUsageLabel(used, limit)
|
||||
: `${formatLimit(used)} / ${formatLimit(limit)}`}
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
<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) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
|
||||
return <LayoutAuthenticated portal='customer'>{page}</LayoutAuthenticated>
|
||||
}
|
||||
|
||||
@ -698,6 +698,7 @@ const EditUsers = () => {
|
||||
EditUsers.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'UPDATE_USERS'}
|
||||
|
||||
|
||||
@ -695,6 +695,7 @@ const EditUsersPage = () => {
|
||||
EditUsersPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'UPDATE_USERS'}
|
||||
|
||||
|
||||
@ -154,6 +154,7 @@ const UsersTablesPage = () => {
|
||||
UsersTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'READ_USERS'}
|
||||
|
||||
|
||||
@ -495,6 +495,7 @@ const UsersNew = () => {
|
||||
UsersNew.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'CREATE_USERS'}
|
||||
|
||||
|
||||
@ -152,6 +152,7 @@ const UsersTablesPage = () => {
|
||||
UsersTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'READ_USERS'}
|
||||
|
||||
|
||||
@ -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
|
||||
className='mb-6 border border-gray-300 rounded overflow-hidden'
|
||||
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>
|
||||
@ -585,6 +585,7 @@ const UsersView = () => {
|
||||
UsersView.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
portal='admin'
|
||||
|
||||
permission={'READ_USERS'}
|
||||
|
||||
|
||||
@ -158,7 +158,7 @@ export const businessesSlice = createSlice({
|
||||
|
||||
builder.addCase(deleteItemsByIds.fulfilled, (state) => {
|
||||
state.loading = false;
|
||||
fulfilledNotify(state, 'Businesses has been deleted');
|
||||
fulfilledNotify(state, 'Businesses have been deleted');
|
||||
});
|
||||
|
||||
builder.addCase(deleteItemsByIds.rejected, (state, action) => {
|
||||
@ -173,7 +173,7 @@ export const businessesSlice = createSlice({
|
||||
|
||||
builder.addCase(deleteItem.fulfilled, (state) => {
|
||||
state.loading = false
|
||||
fulfilledNotify(state, `${'Businesses'.slice(0, -1)} has been deleted`);
|
||||
fulfilledNotify(state, 'Business has been deleted');
|
||||
})
|
||||
|
||||
builder.addCase(deleteItem.rejected, (state, action) => {
|
||||
@ -192,7 +192,7 @@ export const businessesSlice = createSlice({
|
||||
|
||||
builder.addCase(create.fulfilled, (state) => {
|
||||
state.loading = false
|
||||
fulfilledNotify(state, `${'Businesses'.slice(0, -1)} has been created`);
|
||||
fulfilledNotify(state, 'Business has been created');
|
||||
})
|
||||
|
||||
builder.addCase(update.pending, (state) => {
|
||||
@ -201,7 +201,7 @@ export const businessesSlice = createSlice({
|
||||
})
|
||||
builder.addCase(update.fulfilled, (state) => {
|
||||
state.loading = false
|
||||
fulfilledNotify(state, `${'Businesses'.slice(0, -1)} has been updated`);
|
||||
fulfilledNotify(state, 'Business has been updated');
|
||||
})
|
||||
builder.addCase(update.rejected, (state, action) => {
|
||||
state.loading = false
|
||||
@ -214,7 +214,7 @@ export const businessesSlice = createSlice({
|
||||
})
|
||||
builder.addCase(uploadCsv.fulfilled, (state) => {
|
||||
state.loading = false;
|
||||
fulfilledNotify(state, 'Businesses has been uploaded');
|
||||
fulfilledNotify(state, 'Businesses have been uploaded');
|
||||
})
|
||||
builder.addCase(uploadCsv.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
|
||||
@ -38,7 +38,7 @@ export const subscriptionPlans: SubscriptionPlan[] = [
|
||||
'Manual review request creation',
|
||||
'Hosted public review form',
|
||||
'Customer management',
|
||||
'Business/location management',
|
||||
'Business profile management',
|
||||
'Transaction tracking',
|
||||
'Stripe, Square, PayPal, Shopify, and WooCommerce webhook intake',
|
||||
'Review request status tracking',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user