diff --git a/backend/src/services/subscription.js b/backend/src/services/subscription.js index 985d53f..64241a6 100644 --- a/backend/src/services/subscription.js +++ b/backend/src/services/subscription.js @@ -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); } diff --git a/backend/src/services/subscriptionPlans.js b/backend/src/services/subscriptionPlans.js index a244087..272ad1d 100644 --- a/backend/src/services/subscriptionPlans.js +++ b/backend/src/services/subscriptionPlans.js @@ -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', diff --git a/frontend/src/components/Businesses/CardBusinesses.tsx b/frontend/src/components/Businesses/CardBusinesses.tsx index e7af85e..174968b 100644 --- a/frontend/src/components/Businesses/CardBusinesses.tsx +++ b/frontend/src/components/Businesses/CardBusinesses.tsx @@ -90,7 +90,7 @@ const CardBusinesses = ({
-
BusinessName
+
Business name
{ item.name } @@ -102,7 +102,7 @@ const CardBusinesses = ({
-
GoogleReviewLink
+
Google review link
{ item.google_review_link } @@ -114,7 +114,7 @@ const CardBusinesses = ({
-
YelpReviewLink
+
Yelp review link
{ item.yelp_review_link } @@ -126,7 +126,7 @@ const CardBusinesses = ({
-
FacebookReviewLink
+
Facebook review link
{ item.facebook_review_link } @@ -138,7 +138,7 @@ const CardBusinesses = ({
-
DelayDays
+
Review delay days
{ item.delay_days } @@ -150,7 +150,7 @@ const CardBusinesses = ({
-
EmailSubjectTemplate
+
Email subject template
{ item.email_subject_template } @@ -162,7 +162,7 @@ const CardBusinesses = ({
-
EmailBodyTemplate
+
Email body template
{ item.email_body_template } @@ -174,7 +174,7 @@ const CardBusinesses = ({
-
IsActive
+
Active
{ dataFormatter.booleanFormatter(item.is_active) } @@ -186,7 +186,7 @@ const CardBusinesses = ({
-
StripeAccountReference
+
Stripe account reference
{ item.stripe_account_reference } @@ -198,7 +198,7 @@ const CardBusinesses = ({
-
StripeConnected
+
Stripe connected
{ dataFormatter.booleanFormatter(item.stripe_connected) } @@ -210,7 +210,7 @@ const CardBusinesses = ({
-
StripeConnectedAt
+
Stripe connected at
{ dataFormatter.dateTimeFormatter(item.stripe_connected_at) } @@ -222,7 +222,7 @@ const CardBusinesses = ({
-
DefaultReviewPlatform
+
Default review platform
{ item.default_review_platform } @@ -234,7 +234,7 @@ const CardBusinesses = ({
-
CustomReviewLink
+
Custom review link
{ item.custom_review_link } diff --git a/frontend/src/components/Businesses/ListBusinesses.tsx b/frontend/src/components/Businesses/ListBusinesses.tsx index d631560..8b46058 100644 --- a/frontend/src/components/Businesses/ListBusinesses.tsx +++ b/frontend/src/components/Businesses/ListBusinesses.tsx @@ -56,7 +56,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
-

BusinessName

+

Business name

{ item.name }

@@ -64,7 +64,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
-

GoogleReviewLink

+

Google review link

{ item.google_review_link }

@@ -72,7 +72,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
-

YelpReviewLink

+

Yelp review link

{ item.yelp_review_link }

@@ -80,7 +80,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
-

FacebookReviewLink

+

Facebook review link

{ item.facebook_review_link }

@@ -88,7 +88,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
-

DelayDays

+

Review delay days

{ item.delay_days }

@@ -96,7 +96,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
-

EmailSubjectTemplate

+

Email subject template

{ item.email_subject_template }

@@ -104,7 +104,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
-

EmailBodyTemplate

+

Email body template

{ item.email_body_template }

@@ -112,7 +112,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
-

IsActive

+

Active

{ dataFormatter.booleanFormatter(item.is_active) }

@@ -120,7 +120,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
-

StripeAccountReference

+

Stripe account reference

{ item.stripe_account_reference }

@@ -128,7 +128,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
-

StripeConnected

+

Stripe connected

{ dataFormatter.booleanFormatter(item.stripe_connected) }

@@ -136,7 +136,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
-

StripeConnectedAt

+

Stripe connected at

{ dataFormatter.dateTimeFormatter(item.stripe_connected_at) }

@@ -144,7 +144,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
-

DefaultReviewPlatform

+

Default review platform

{ item.default_review_platform }

@@ -152,7 +152,7 @@ const ListBusinesses = ({ businesses, loading, onDelete, currentPage, numPages,
-

CustomReviewLink

+

Custom review link

{ item.custom_review_link }

diff --git a/frontend/src/components/Businesses/configureBusinessesCols.tsx b/frontend/src/components/Businesses/configureBusinessesCols.tsx index 297472f..b03e6ca 100644 --- a/frontend/src/components/Businesses/configureBusinessesCols.tsx +++ b/frontend/src/components/Businesses/configureBusinessesCols.tsx @@ -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, diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index fcbd9b9..f903858 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -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 ? ( + + {itemLabel} + {portalLabel} + + ) : itemLabel} {item.isCurrentUser && } {item.menu && ( diff --git a/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx b/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx index b85e8ff..06f3ff5 100644 --- a/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx +++ b/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx @@ -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({

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

= { 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(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 diff --git a/frontend/src/helpers/businessPlanLabels.ts b/frontend/src/helpers/businessPlanLabels.ts new file mode 100644 index 0000000..9342c94 --- /dev/null +++ b/frontend/src/helpers/businessPlanLabels.ts @@ -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)}` +} diff --git a/frontend/src/helpers/portalRoles.ts b/frontend/src/helpers/portalRoles.ts new file mode 100644 index 0000000..e19d343 --- /dev/null +++ b/frontend/src/helpers/portalRoles.ts @@ -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' +} diff --git a/frontend/src/helpers/userPermissions.ts b/frontend/src/helpers/userPermissions.ts index 2f9c9f9..addf480 100644 --- a/frontend/src/helpers/userPermissions.ts +++ b/frontend/src/helpers/userPermissions.ts @@ -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([ ...(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)) } diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 73d8391..3626286 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -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({ setIsAsideLgActive(false)} /> {children} - Hand-crafted & Made with ❤️ + {portalLabel} · ReviewFlow
) diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index e44f962..e517baa 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -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 diff --git a/frontend/src/pages/businesses/[businessesId].tsx b/frontend/src/pages/businesses/[businessesId].tsx index 39921f3..b86bd15 100644 --- a/frontend/src/pages/businesses/[businessesId].tsx +++ b/frontend/src/pages/businesses/[businessesId].tsx @@ -469,10 +469,10 @@ const EditBusinesses = () => { return ( <> - {getPageTitle('Edit businesses')} + {getPageTitle('Edit Business')} - + {''} @@ -550,11 +550,11 @@ const EditBusinesses = () => { @@ -587,11 +587,11 @@ const EditBusinesses = () => { @@ -624,11 +624,11 @@ const EditBusinesses = () => { @@ -661,11 +661,11 @@ const EditBusinesses = () => { @@ -704,12 +704,12 @@ const EditBusinesses = () => { @@ -736,11 +736,11 @@ const EditBusinesses = () => { @@ -776,7 +776,7 @@ const EditBusinesses = () => { - + { - + { @@ -897,7 +897,7 @@ const EditBusinesses = () => { - + { { - + @@ -1003,11 +1003,11 @@ const EditBusinesses = () => { diff --git a/frontend/src/pages/businesses/businesses-edit.tsx b/frontend/src/pages/businesses/businesses-edit.tsx index 6f0b8bd..d9d5989 100644 --- a/frontend/src/pages/businesses/businesses-edit.tsx +++ b/frontend/src/pages/businesses/businesses-edit.tsx @@ -466,10 +466,10 @@ const EditBusinessesPage = () => { return ( <> - {getPageTitle('Edit businesses')} + {getPageTitle('Edit Business')} - + {''} @@ -547,11 +547,11 @@ const EditBusinessesPage = () => { @@ -584,11 +584,11 @@ const EditBusinessesPage = () => { @@ -621,11 +621,11 @@ const EditBusinessesPage = () => { @@ -658,11 +658,11 @@ const EditBusinessesPage = () => { @@ -701,12 +701,12 @@ const EditBusinessesPage = () => { @@ -733,11 +733,11 @@ const EditBusinessesPage = () => { @@ -773,7 +773,7 @@ const EditBusinessesPage = () => { - + { - + { @@ -894,7 +894,7 @@ const EditBusinessesPage = () => { - + { { - + @@ -1000,11 +1000,11 @@ const EditBusinessesPage = () => { diff --git a/frontend/src/pages/businesses/businesses-list.tsx b/frontend/src/pages/businesses/businesses-list.tsx index fc63460..7d7e074 100644 --- a/frontend/src/pages/businesses/businesses-list.tsx +++ b/frontend/src/pages/businesses/businesses-list.tsx @@ -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 ( <> - {getPageTitle('Businesses')} + {getPageTitle(businessPageTitle)} - + {''} - + +

{businessPageTitle} setup

+

+ {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.'} +

+
+ + - {hasCreatePermission && } + {hasCreatePermission && } { return ( <> - {getPageTitle('New Item')} + {getPageTitle('Add Business')} - + {''} { @@ -361,11 +361,11 @@ const BusinessesNew = () => { @@ -396,11 +396,11 @@ const BusinessesNew = () => { @@ -431,11 +431,11 @@ const BusinessesNew = () => { @@ -472,12 +472,12 @@ const BusinessesNew = () => { @@ -502,11 +502,11 @@ const BusinessesNew = () => { @@ -540,7 +540,7 @@ const BusinessesNew = () => { - + { - + { @@ -655,7 +655,7 @@ const BusinessesNew = () => { - + { @@ -723,7 +723,7 @@ const BusinessesNew = () => { - + @@ -750,11 +750,11 @@ const BusinessesNew = () => { diff --git a/frontend/src/pages/businesses/businesses-table.tsx b/frontend/src/pages/businesses/businesses-table.tsx index 815f4e9..19b99d2 100644 --- a/frontend/src/pages/businesses/businesses-table.tsx +++ b/frontend/src/pages/businesses/businesses-table.tsx @@ -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 ( <> - {getPageTitle('Businesses')} + {getPageTitle(businessPageTitle)} - + {''} + +

{businessPageTitle} setup

+

+ {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.'} +

+
+ - {hasCreatePermission && } + {hasCreatePermission && } { 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 ( <> - {getPageTitle('View businesses')} + {getPageTitle('View Business')} - + {
-

BusinessName

+

Business name

{businesses?.name}

@@ -145,7 +140,7 @@ const BusinessesView = () => {
-

GoogleReviewLink

+

Google review link

{businesses?.google_review_link}

@@ -177,7 +172,7 @@ const BusinessesView = () => {
-

YelpReviewLink

+

Yelp review link

{businesses?.yelp_review_link}

@@ -209,7 +204,7 @@ const BusinessesView = () => {
-

FacebookReviewLink

+

Facebook review link

{businesses?.facebook_review_link}

@@ -247,7 +242,7 @@ const BusinessesView = () => {
-

DelayDays

+

Review delay days

{businesses?.delay_days || 'No data'}

@@ -273,7 +268,7 @@ const BusinessesView = () => {
-

EmailSubjectTemplate

+

Email subject template

{businesses?.email_subject_template}

@@ -309,7 +304,7 @@ const BusinessesView = () => {
-

EmailBodyTemplate

+

Email body template

{businesses.email_body_template ?

:

No data

@@ -355,7 +350,7 @@ const BusinessesView = () => { - + null}} @@ -375,7 +370,7 @@ const BusinessesView = () => {
-

StripeAccountReference

+

Stripe account reference

{businesses?.stripe_account_reference}

@@ -422,7 +417,7 @@ const BusinessesView = () => { - + null}} @@ -451,7 +446,7 @@ const BusinessesView = () => { - + {businesses.stripe_connected_at ? { ) : null } disabled - /> :

No StripeConnectedAt

} + /> :

No Stripe connection date

}
@@ -496,7 +491,7 @@ const BusinessesView = () => {
-

DefaultReviewPlatform

+

Default review platform

{businesses?.default_review_platform ?? 'No data'}

@@ -514,7 +509,7 @@ const BusinessesView = () => {
-

CustomReviewLink

+

Custom review link

{businesses?.custom_review_link}

@@ -550,7 +545,7 @@ const BusinessesView = () => { <> -

Customers Business

+

Customers for this business

{page}; + return {page}; }; diff --git a/frontend/src/pages/cron_runs/[cron_runsId].tsx b/frontend/src/pages/cron_runs/[cron_runsId].tsx index 7ffb047..9adff68 100644 --- a/frontend/src/pages/cron_runs/[cron_runsId].tsx +++ b/frontend/src/pages/cron_runs/[cron_runsId].tsx @@ -645,6 +645,7 @@ const EditCron_runs = () => { EditCron_runs.getLayout = function getLayout(page: ReactElement) { return ( { EditCron_runsPage.getLayout = function getLayout(page: ReactElement) { return ( { Cron_runsTablesPage.getLayout = function getLayout(page: ReactElement) { return ( { Cron_runsNew.getLayout = function getLayout(page: ReactElement) { return ( { Cron_runsTablesPage.getLayout = function getLayout(page: ReactElement) { return ( { Cron_runsView.getLayout = function getLayout(page: ReactElement) { return ( + +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 = { + 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 ( + +
+
+
+
+ {card.label} +
+
+ {formatCount(value)} +
+

+ {card.description} +

+
+ +
+
+ + ) +} + +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 ( + +
+
+

{group.title}

+

{group.description}

+
+
+ {visibleActions.map((action) => ( + + ))} +
+
+
+ ) +} + +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 ( + +
+
+

+ {portalLabel} +

+

Welcome, {name}

+

+ {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.'} +

+
+
+

Signed in as

+

{roleName}

+

+ {adminPortal + ? 'Customer workspace setup links are intentionally hidden from this portal.' + : 'Internal platform administration links are intentionally hidden from this workspace.'} +

+
+
+
+ ) +} -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(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 ( <> - - {getPageTitle('Overview')} - + {getPageTitle(title)} - + {''} - - {hasPermission(currentUser, 'CREATE_ROLES') && + + {showCustomerWidgets && ( + } - {!!rolesWidgets.length && - hasPermission(currentUser, 'CREATE_ROLES') && ( -

- {`${widgetsRole?.role?.label || 'Users'}'s widgets`} -

- )} + /> + )} + {!!rolesWidgets.length && showCustomerWidgets && ( +

+ {`${widgetsRole?.role?.label || 'Users'}'s widgets`} +

+ )} -
+ {!adminPortal && ( +
{(isFetchingQuery || loading) && ( -
- {' '} - Loading widgets... -
+
+ {' '} + Loading widgets... +
)} - { rolesWidgets && - rolesWidgets.map((widget) => ( - + {rolesWidgets.map((widget) => ( + ))} +
+ )} + + {!adminPortal && !!rolesWidgets.length &&
} + +
+ {visibleCards.map((card) => ( + + ))}
- {!!rolesWidgets.length &&
} - -
- - - {hasPermission(currentUser, 'READ_USERS') && -
-
-
-
- Users -
-
- {users} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_ROLES') && -
-
-
-
- Roles -
-
- {roles} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_PERMISSIONS') && -
-
-
-
- Permissions -
-
- {permissions} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_BUSINESSES') && -
-
-
-
- Businesses -
-
- {businesses} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_CUSTOMERS') && -
-
-
-
- Customers -
-
- {customers} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_TRANSACTIONS') && -
-
-
-
- Transactions -
-
- {transactions} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_REVIEW_REQUESTS') && -
-
-
-
- Review requests -
-
- {review_requests} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_STRIPE_EVENTS') && -
-
-
-
- Stripe events -
-
- {stripe_events} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_EMAIL_DELIVERY_LOGS') && -
-
-
-
- Email delivery logs -
-
- {email_delivery_logs} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_CRON_RUNS') && -
-
-
-
- Cron runs -
-
- {cron_runs} -
-
-
- -
-
-
- } - - +
+ {actionGroups.map((group) => ( + + ))}
diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index dda5ad9..807cce5 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -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() {
@@ -71,7 +72,7 @@ export default function Starter() {

- +
{metrics.map(([value, label]) => ( @@ -181,7 +182,7 @@ export default function Starter() {

{plan.limits.businesses}

-

businesses/locations

+

{getBusinessProfileNoun(plan.limits.businesses)}

{plan.limits.teamMembers}

@@ -230,7 +231,7 @@ export default function Starter() {

First MVP slice

A complete thin workflow, not just a screen.

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

diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index ce9da92..60a8c79 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -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{' / '} fc6e39e3{' / '} - to login as Admin

+ to login as Internal Admin

Use setLogin(e.target)}>client@hello.com{' / '} + onClick={(e) => setLogin(e.target)}>john@doe.com{' / '} 874c3b951385{' / '} - to login as User

+ to login as Customer Owner

diff --git a/frontend/src/pages/permissions/[permissionsId].tsx b/frontend/src/pages/permissions/[permissionsId].tsx index 38e75d0..7ba2847 100644 --- a/frontend/src/pages/permissions/[permissionsId].tsx +++ b/frontend/src/pages/permissions/[permissionsId].tsx @@ -175,6 +175,7 @@ const EditPermissions = () => { EditPermissions.getLayout = function getLayout(page: ReactElement) { return ( { EditPermissionsPage.getLayout = function getLayout(page: ReactElement) { return ( { PermissionsTablesPage.getLayout = function getLayout(page: ReactElement) { return ( { PermissionsNew.getLayout = function getLayout(page: ReactElement) { return ( { PermissionsTablesPage.getLayout = function getLayout(page: ReactElement) { return ( { PermissionsView.getLayout = function getLayout(page: ReactElement) { return (
@@ -476,7 +477,7 @@ export default function ReviewFlowWorkspace() { Unlock advanced reputation growth tools.

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

{page}; + return {page}; }; diff --git a/frontend/src/pages/roles/[rolesId].tsx b/frontend/src/pages/roles/[rolesId].tsx index c233151..549388e 100644 --- a/frontend/src/pages/roles/[rolesId].tsx +++ b/frontend/src/pages/roles/[rolesId].tsx @@ -264,6 +264,7 @@ const EditRoles = () => { EditRoles.getLayout = function getLayout(page: ReactElement) { return ( { EditRolesPage.getLayout = function getLayout(page: ReactElement) { return ( { RolesTablesPage.getLayout = function getLayout(page: ReactElement) { return ( { RolesNew.getLayout = function getLayout(page: ReactElement) { return ( { RolesTablesPage.getLayout = function getLayout(page: ReactElement) { return ( { RolesView.getLayout = function getLayout(page: ReactElement) { return ( { EditStripe_events.getLayout = function getLayout(page: ReactElement) { return ( { EditStripe_eventsPage.getLayout = function getLayout(page: ReactElement) { return ( { Stripe_eventsTablesPage.getLayout = function getLayout(page: ReactElement) { return ( { Stripe_eventsNew.getLayout = function getLayout(page: ReactElement) { return ( { Stripe_eventsTablesPage.getLayout = function getLayout(page: ReactElement) { return ( { Stripe_eventsView.getLayout = function getLayout(page: ReactElement) { return ( = [ { 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() {

{item.label}

- {formatLimit(used)} / {formatLimit(limit)} + {item.limitKey === 'businesses' + ? getBusinessProfileUsageLabel(used, limit) + : `${formatLimit(used)} / ${formatLimit(limit)}`}

@@ -362,7 +365,7 @@ export default function SubscriptionPage() {

{formatLimit(plan.limits.businesses)}

-

businesses

+

{getBusinessProfileNoun(plan.limits.businesses)}

{formatLimit(plan.limits.teamMembers)}

@@ -405,5 +408,5 @@ export default function SubscriptionPage() { } SubscriptionPage.getLayout = function getLayout(page: ReactElement) { - return {page} + return {page} } diff --git a/frontend/src/pages/users/[usersId].tsx b/frontend/src/pages/users/[usersId].tsx index 4b6477c..0efb8fe 100644 --- a/frontend/src/pages/users/[usersId].tsx +++ b/frontend/src/pages/users/[usersId].tsx @@ -698,6 +698,7 @@ const EditUsers = () => { EditUsers.getLayout = function getLayout(page: ReactElement) { return ( { EditUsersPage.getLayout = function getLayout(page: ReactElement) { return ( { UsersTablesPage.getLayout = function getLayout(page: ReactElement) { return ( { UsersNew.getLayout = function getLayout(page: ReactElement) { return ( { UsersTablesPage.getLayout = function getLayout(page: ReactElement) { return ( { <> -

Businesses Owner

+

Business profiles owned

{ - BusinessName + Business name - GoogleReviewLink + Google review link - YelpReviewLink + Yelp review link - FacebookReviewLink + Facebook review link - DelayDays + Review delay days - EmailSubjectTemplate + Email subject template - IsActive + Active - StripeAccountReference + Stripe account reference - StripeConnected + Stripe connected - StripeConnectedAt + Stripe connected at - DefaultReviewPlatform + Default review platform - CustomReviewLink + Custom review link @@ -585,6 +585,7 @@ const UsersView = () => { UsersView.getLayout = function getLayout(page: ReactElement) { return ( { 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; diff --git a/frontend/src/subscriptionPlans.ts b/frontend/src/subscriptionPlans.ts index d417c16..118ce96 100644 --- a/frontend/src/subscriptionPlans.ts +++ b/frontend/src/subscriptionPlans.ts @@ -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',