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

View File

@ -8,6 +8,7 @@ const {
const DEFAULT_PLAN_ID = 'starter';
const DEFAULT_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);
}

View File

@ -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',

View File

@ -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 }

View File

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

View File

@ -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,

View File

@ -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 && (

View File

@ -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'

View File

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

View File

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

View File

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

View File

@ -1,18 +1,21 @@
export function hasPermission(user, permission_name: string | string[]) {
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))
}

View File

@ -1,7 +1,7 @@
import React, { ReactNode, useEffect, useState } from 'react'
import React, { ReactNode, useEffect, useMemo, useState } from 'react'
import jwt from 'jsonwebtoken';
import { 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>
)

View File

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

View File

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

View File

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

View File

@ -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'}

View File

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

View File

@ -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'}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,431 +1,546 @@
import * as icon from '@mdi/js';
import * as icon from '@mdi/js'
import Head from 'next/head'
import 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>
</>

View File

@ -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">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>;
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -407,7 +407,7 @@ const UsersView = () => {
<>
<p className={'block font-bold mb-2'}>Businesses Owner</p>
<p className={'block font-bold mb-2'}>Business profiles owned</p>
<CardBox
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'}

View File

@ -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;

View File

@ -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',