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:
Alireza Hassani
2026-07-22 13:48:53 +03:30
co-authored by Cursor
commit f566387c61
509 changed files with 62690 additions and 0 deletions
+149
View File
@@ -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
}