Initial commit: Meshkee dashboards monorepo.
Includes business, customer, and super-admin apps with shared packages and production deploy scripts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { ApiError, getAccessToken, isAbortError } from '../lib/api'
|
||||
import {
|
||||
setActiveBusinessById,
|
||||
} from '../lib/businessContext'
|
||||
import { getBusinessDomain } from '../lib/config'
|
||||
import {
|
||||
fetchCurrentUser,
|
||||
login as loginRequest,
|
||||
logout as logoutRequest,
|
||||
} from '../services/authService'
|
||||
import { resolveTenantByDomain } from '../services/tenantService'
|
||||
import type { AuthUser } from '../types/auth'
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null
|
||||
isLoading: boolean
|
||||
login: (cellNumber: string, password: string) => Promise<void>
|
||||
logout: () => void
|
||||
setUser: (user: AuthUser) => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
|
||||
const BUSINESS_ACCESS_MESSAGE =
|
||||
'This account cannot access the business dashboard. Only business owners and staff can sign in here.'
|
||||
|
||||
function isSuperAdminUser(user: AuthUser) {
|
||||
return user.isSuperAdmin === true || user.roles.includes('super_admin')
|
||||
}
|
||||
|
||||
function canAccessBusinessDashboard(user: AuthUser) {
|
||||
if (isSuperAdminUser(user)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return user.dashboard === 'business' && user.businesses.length > 0
|
||||
}
|
||||
|
||||
async function prepareBusinessContext(user: AuthUser) {
|
||||
const domain = getBusinessDomain()
|
||||
|
||||
if (isSuperAdminUser(user)) {
|
||||
const tenant = await resolveTenantByDomain(domain)
|
||||
setActiveBusinessById(String(tenant.id), tenant.domain)
|
||||
return
|
||||
}
|
||||
|
||||
const membership =
|
||||
user.businesses.find((business) => business.slug === tenantSlugFromDomain(domain)) ??
|
||||
user.businesses[0]
|
||||
|
||||
if (!membership) {
|
||||
throw new ApiError('No business membership found for this account.', 403)
|
||||
}
|
||||
|
||||
setActiveBusinessById(String(membership.id), domain)
|
||||
}
|
||||
|
||||
function tenantSlugFromDomain(domain: string) {
|
||||
return domain.split('.')[0]?.toLowerCase() ?? domain
|
||||
}
|
||||
|
||||
async function assertBusinessAccess(user: AuthUser) {
|
||||
if (!canAccessBusinessDashboard(user)) {
|
||||
logoutRequest()
|
||||
throw new ApiError(BUSINESS_ACCESS_MESSAGE, 403)
|
||||
}
|
||||
|
||||
await prepareBusinessContext(user)
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const logout = useCallback(() => {
|
||||
logoutRequest()
|
||||
setUser(null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
async function init() {
|
||||
if (!getAccessToken()) {
|
||||
if (!controller.signal.aborted) setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { user: currentUser } = await fetchCurrentUser(controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
if (!canAccessBusinessDashboard(currentUser)) {
|
||||
logout()
|
||||
return
|
||||
}
|
||||
|
||||
await prepareBusinessContext(currentUser)
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
setUser(currentUser)
|
||||
} catch (err) {
|
||||
if (isAbortError(err) || controller.signal.aborted) return
|
||||
logout()
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void init()
|
||||
|
||||
return () => {
|
||||
controller.abort()
|
||||
}
|
||||
}, [logout])
|
||||
|
||||
const login = useCallback(async (cellNumber: string, password: string) => {
|
||||
const data = await loginRequest(cellNumber, password)
|
||||
await assertBusinessAccess(data.user)
|
||||
setUser(data.user)
|
||||
}, [])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ user, isLoading, login, logout, setUser }),
|
||||
[user, isLoading, login, logout],
|
||||
)
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext)
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export { BUSINESS_ACCESS_MESSAGE }
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
import { useAuth } from './AuthContext'
|
||||
import { getActiveBusinessId } from '../lib/businessContext'
|
||||
import { isAbortError } from '../lib/api'
|
||||
import { getSettings } from '../services/settingsService'
|
||||
import { applyBusinessPrimaryColor, resetBusinessPrimaryColor } from '../utils/applyBusinessTheme'
|
||||
import { normalizeBusinessPrimaryColorId } from '../utils/businessPrimaryColors'
|
||||
|
||||
export function BusinessThemeProvider({ children }: { children: ReactNode }) {
|
||||
const { user, isLoading } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return
|
||||
|
||||
if (!user || !getActiveBusinessId()) {
|
||||
resetBusinessPrimaryColor()
|
||||
return
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
|
||||
async function loadTheme() {
|
||||
try {
|
||||
const data = await getSettings(controller.signal)
|
||||
applyBusinessPrimaryColor(
|
||||
normalizeBusinessPrimaryColorId(data.settings.branding?.primaryColor),
|
||||
)
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
applyBusinessPrimaryColor(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
void loadTheme()
|
||||
|
||||
return () => {
|
||||
controller.abort()
|
||||
}
|
||||
}, [user, isLoading])
|
||||
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import type { BusinessCustomer } from '../services/customerService'
|
||||
import type { ShoppingCard } from '../services/shoppingCardService'
|
||||
import type { StoreItem } from '../services/storeItemService'
|
||||
import {
|
||||
draftCartItemCount,
|
||||
draftCartSubtotal,
|
||||
shoppingCardCustomerToBusinessCustomer,
|
||||
shoppingCardToDraftItems,
|
||||
storeItemToDraftCartItem,
|
||||
RESUME_SHOPPING_CARD_KEY,
|
||||
type DraftCartItem,
|
||||
} from '../types/draftCart'
|
||||
|
||||
interface DraftCartContextValue {
|
||||
items: DraftCartItem[]
|
||||
customer: BusinessCustomer | null
|
||||
resumedShoppingCardId: string | null
|
||||
itemCount: number
|
||||
subtotal: number
|
||||
hasItems: boolean
|
||||
addVariant: (variant: StoreItem, quantity?: number) => string | null
|
||||
updateQuantity: (storeItemVariantId: string, quantity: number) => void
|
||||
removeItem: (storeItemVariantId: string) => void
|
||||
setCustomer: (customer: BusinessCustomer | null) => void
|
||||
loadShoppingCard: (card: ShoppingCard) => void
|
||||
clear: () => void
|
||||
clearAll: () => void
|
||||
}
|
||||
|
||||
const DraftCartContext = createContext<DraftCartContextValue | null>(null)
|
||||
|
||||
export function DraftCartProvider({ children }: { children: ReactNode }) {
|
||||
const [items, setItems] = useState<DraftCartItem[]>([])
|
||||
const [customer, setCustomer] = useState<BusinessCustomer | null>(null)
|
||||
const [resumedShoppingCardId, setResumedShoppingCardId] = useState<string | null>(null)
|
||||
|
||||
const addVariant = useCallback((variant: StoreItem, quantity = 1) => {
|
||||
const draft = storeItemToDraftCartItem(variant, quantity)
|
||||
if (!draft) {
|
||||
return 'This variant has no price configured.'
|
||||
}
|
||||
|
||||
if (draft.stockQuantity !== null && quantity > draft.stockQuantity) {
|
||||
return `Only ${draft.stockQuantity} in stock.`
|
||||
}
|
||||
|
||||
let error: string | null = null
|
||||
|
||||
setItems((prev) => {
|
||||
const existing = prev.find(
|
||||
(item) => item.storeItemVariantId === draft.storeItemVariantId,
|
||||
)
|
||||
|
||||
if (existing) {
|
||||
const newQuantity = existing.quantity + quantity
|
||||
if (
|
||||
existing.stockQuantity !== null &&
|
||||
newQuantity > existing.stockQuantity
|
||||
) {
|
||||
error = `Only ${existing.stockQuantity} in stock.`
|
||||
return prev
|
||||
}
|
||||
|
||||
return prev.map((item) =>
|
||||
item.storeItemVariantId === draft.storeItemVariantId
|
||||
? { ...item, quantity: newQuantity }
|
||||
: item,
|
||||
)
|
||||
}
|
||||
|
||||
return [...prev, draft]
|
||||
})
|
||||
|
||||
return error
|
||||
}, [])
|
||||
|
||||
const updateQuantity = useCallback((storeItemVariantId: string, quantity: number) => {
|
||||
if (quantity < 1) {
|
||||
setItems((prev) =>
|
||||
prev.filter((item) => item.storeItemVariantId !== storeItemVariantId),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setItems((prev) =>
|
||||
prev.map((item) => {
|
||||
if (item.storeItemVariantId !== storeItemVariantId) return item
|
||||
if (item.stockQuantity !== null && quantity > item.stockQuantity) {
|
||||
return item
|
||||
}
|
||||
return { ...item, quantity }
|
||||
}),
|
||||
)
|
||||
}, [])
|
||||
|
||||
const removeItem = useCallback((storeItemVariantId: string) => {
|
||||
setItems((prev) =>
|
||||
prev.filter((item) => item.storeItemVariantId !== storeItemVariantId),
|
||||
)
|
||||
}, [])
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setItems([])
|
||||
}, [])
|
||||
|
||||
const clearAll = useCallback(() => {
|
||||
setItems([])
|
||||
setCustomer(null)
|
||||
setResumedShoppingCardId(null)
|
||||
sessionStorage.removeItem(RESUME_SHOPPING_CARD_KEY)
|
||||
}, [])
|
||||
|
||||
const loadShoppingCard = useCallback((card: ShoppingCard) => {
|
||||
setCustomer(shoppingCardCustomerToBusinessCustomer(card.customer))
|
||||
setItems(shoppingCardToDraftItems(card))
|
||||
setResumedShoppingCardId(card.id)
|
||||
}, [])
|
||||
|
||||
const value = useMemo<DraftCartContextValue>(
|
||||
() => ({
|
||||
items,
|
||||
customer,
|
||||
resumedShoppingCardId,
|
||||
itemCount: draftCartItemCount(items),
|
||||
subtotal: draftCartSubtotal(items),
|
||||
hasItems: items.length > 0,
|
||||
addVariant,
|
||||
updateQuantity,
|
||||
removeItem,
|
||||
setCustomer,
|
||||
loadShoppingCard,
|
||||
clear,
|
||||
clearAll,
|
||||
}),
|
||||
[
|
||||
items,
|
||||
customer,
|
||||
resumedShoppingCardId,
|
||||
addVariant,
|
||||
updateQuantity,
|
||||
removeItem,
|
||||
loadShoppingCard,
|
||||
clear,
|
||||
clearAll,
|
||||
],
|
||||
)
|
||||
|
||||
return (
|
||||
<DraftCartContext.Provider value={value}>{children}</DraftCartContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useDraftCart() {
|
||||
const context = useContext(DraftCartContext)
|
||||
if (!context) {
|
||||
throw new Error('useDraftCart must be used within DraftCartProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { applyDocumentFavicon } from '@meshkee/dashboard-core'
|
||||
import { isAbortError } from '../lib/api'
|
||||
import { getBusinessDomain } from '../lib/config'
|
||||
import { BUSINESS_PROFILE_UPDATED_EVENT } from '../lib/businessContext'
|
||||
import { getBusinessProfile } from '../services/businessProfileService'
|
||||
import { resolveTenantByDomain } from '../services/tenantService'
|
||||
|
||||
interface TenantBrandingContextValue {
|
||||
businessName: string
|
||||
faviconUrl: string | null
|
||||
refreshBranding: () => void
|
||||
}
|
||||
|
||||
const TenantBrandingContext = createContext<TenantBrandingContextValue | null>(null)
|
||||
|
||||
function pickBusinessName(
|
||||
...candidates: Array<string | null | undefined>
|
||||
): string {
|
||||
for (const value of candidates) {
|
||||
const trimmed = value?.trim()
|
||||
if (trimmed) return trimmed
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function TenantBrandingProvider({ children }: { children: ReactNode }) {
|
||||
const [businessName, setBusinessName] = useState('')
|
||||
const [faviconUrl, setFaviconUrl] = useState<string | null>(null)
|
||||
const [refreshToken, setRefreshToken] = useState(0)
|
||||
|
||||
const refreshBranding = useCallback(() => {
|
||||
setRefreshToken((value) => value + 1)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
const domain = getBusinessDomain()
|
||||
|
||||
async function loadBranding() {
|
||||
try {
|
||||
const tenant = await resolveTenantByDomain(domain)
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
let name = pickBusinessName(tenant.name, tenant.nameFa, domain)
|
||||
let nextFavicon =
|
||||
tenant.faviconUrl?.trim() || tenant.logoUrl?.trim() || null
|
||||
|
||||
try {
|
||||
const profile = await getBusinessProfile(controller.signal)
|
||||
if (!controller.signal.aborted) {
|
||||
name = pickBusinessName(
|
||||
profile.profile.nameEn,
|
||||
profile.profile.nameFa,
|
||||
name,
|
||||
domain,
|
||||
)
|
||||
nextFavicon =
|
||||
profile.profile.faviconUrl?.trim() ||
|
||||
profile.profile.logoUrl?.trim() ||
|
||||
nextFavicon
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isAbortError(err)) {
|
||||
// Guest or unauthorized — tenant branding is enough.
|
||||
}
|
||||
}
|
||||
|
||||
if (!controller.signal.aborted) {
|
||||
setBusinessName(name || domain)
|
||||
setFaviconUrl(nextFavicon)
|
||||
applyDocumentFavicon(nextFavicon)
|
||||
}
|
||||
} catch (err) {
|
||||
if (isAbortError(err) || controller.signal.aborted) return
|
||||
setBusinessName(domain)
|
||||
setFaviconUrl(null)
|
||||
applyDocumentFavicon(null)
|
||||
}
|
||||
}
|
||||
|
||||
void loadBranding()
|
||||
|
||||
function handleProfileUpdated() {
|
||||
refreshBranding()
|
||||
}
|
||||
|
||||
window.addEventListener(BUSINESS_PROFILE_UPDATED_EVENT, handleProfileUpdated)
|
||||
return () => {
|
||||
controller.abort()
|
||||
window.removeEventListener(BUSINESS_PROFILE_UPDATED_EVENT, handleProfileUpdated)
|
||||
}
|
||||
}, [refreshToken, refreshBranding])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ businessName, faviconUrl, refreshBranding }),
|
||||
[businessName, faviconUrl, refreshBranding],
|
||||
)
|
||||
|
||||
return (
|
||||
<TenantBrandingContext.Provider value={value}>{children}</TenantBrandingContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTenantBranding() {
|
||||
const context = useContext(TenantBrandingContext)
|
||||
if (!context) {
|
||||
throw new Error('useTenantBranding must be used within TenantBrandingProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import styles from '../components/Toast.module.css'
|
||||
|
||||
export type ToastVariant = 'success' | 'error' | 'info'
|
||||
|
||||
interface ToastItem {
|
||||
id: number
|
||||
message: string
|
||||
variant: ToastVariant
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
showToast: (message: string, variant?: ToastVariant) => void
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null)
|
||||
|
||||
const TOAST_DURATION_MS = 3200
|
||||
const ANIMATION_MS = 200
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toast, setToast] = useState<ToastItem | null>(null)
|
||||
const [closing, setClosing] = useState(false)
|
||||
const idRef = useRef(0)
|
||||
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (dismissTimerRef.current) {
|
||||
clearTimeout(dismissTimerRef.current)
|
||||
dismissTimerRef.current = null
|
||||
}
|
||||
if (closeTimerRef.current) {
|
||||
clearTimeout(closeTimerRef.current)
|
||||
closeTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const dismissToast = useCallback(() => {
|
||||
setClosing(true)
|
||||
closeTimerRef.current = setTimeout(() => {
|
||||
setToast(null)
|
||||
setClosing(false)
|
||||
}, ANIMATION_MS)
|
||||
}, [])
|
||||
|
||||
const showToast = useCallback(
|
||||
(message: string, variant: ToastVariant = 'info') => {
|
||||
clearTimers()
|
||||
idRef.current += 1
|
||||
setClosing(false)
|
||||
setToast({ id: idRef.current, message, variant })
|
||||
|
||||
dismissTimerRef.current = setTimeout(() => {
|
||||
dismissToast()
|
||||
}, TOAST_DURATION_MS)
|
||||
},
|
||||
[clearTimers, dismissToast],
|
||||
)
|
||||
|
||||
useEffect(() => clearTimers, [clearTimers])
|
||||
|
||||
const value = useMemo(() => ({ showToast }), [showToast])
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div className={styles.container} aria-live="polite" aria-atomic="true">
|
||||
{toast && (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`${styles.toast} ${styles[toast.variant]} ${closing ? styles.toastOut : styles.toastIn}`}
|
||||
role="status"
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext)
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within ToastProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
Reference in New Issue
Block a user