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
+141
View File
@@ -0,0 +1,141 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react'
import { ApiError, getAccessToken, isAbortError } from '../lib/api'
import { setActiveBusinessById } from '../lib/businessContext'
import { getTenantDomain } 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)
export const CUSTOMER_ACCESS_MESSAGE =
'This account cannot access the customer dashboard. Please sign in with a verified customer account.'
export const VERIFICATION_REQUIRED_MESSAGE =
'Your mobile number is not verified yet. Please complete SMS verification before accessing the dashboard.'
function isVerifiedUser(user: AuthUser) {
return user.cellVerifiedAt !== null && user.cellVerifiedAt !== undefined
}
function canAccessCustomerDashboard(user: AuthUser) {
return user.customerBusinesses.length > 0
}
async function prepareCustomerContext(user: AuthUser) {
const domain = getTenantDomain()
const tenant = await resolveTenantByDomain(domain)
const membership =
user.customerBusinesses.find((business) => business.id === tenant.id) ??
user.customerBusinesses.find((business) => business.slug === tenant.slug) ??
user.customerBusinesses[0]
if (!membership) {
throw new ApiError('You are not a customer of this business.', 403)
}
setActiveBusinessById(String(membership.id), domain)
}
async function assertCustomerAccess(user: AuthUser) {
if (!canAccessCustomerDashboard(user)) {
logoutRequest()
throw new ApiError(CUSTOMER_ACCESS_MESSAGE, 403)
}
if (!isVerifiedUser(user)) {
logoutRequest()
throw new ApiError(VERIFICATION_REQUIRED_MESSAGE, 403)
}
await prepareCustomerContext(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 (!canAccessCustomerDashboard(currentUser) || !isVerifiedUser(currentUser)) {
logout()
return
}
await prepareCustomerContext(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 assertCustomerAccess(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
}
@@ -0,0 +1,76 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'
import type { Order } from '../services/orderService'
export type DeliveryMode = 'delivery' | 'pickup'
interface CheckoutState {
deliveryMode: DeliveryMode
selectedAddressId: string | null
discountCode: string
placedOrder: Order | null
}
interface CheckoutContextValue extends CheckoutState {
setDeliveryMode: (mode: DeliveryMode) => void
setSelectedAddressId: (id: string | null) => void
setDiscountCode: (code: string) => void
setPlacedOrder: (order: Order | null) => void
resetCheckout: () => void
}
const defaultState: CheckoutState = {
deliveryMode: 'delivery',
selectedAddressId: null,
discountCode: '',
placedOrder: null,
}
const CheckoutContext = createContext<CheckoutContextValue | null>(null)
export function CheckoutProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<CheckoutState>(defaultState)
const setDeliveryMode = useCallback((deliveryMode: DeliveryMode) => {
setState((prev) => ({ ...prev, deliveryMode }))
}, [])
const setSelectedAddressId = useCallback((selectedAddressId: string | null) => {
setState((prev) =>
prev.selectedAddressId === selectedAddressId ? prev : { ...prev, selectedAddressId },
)
}, [])
const setDiscountCode = useCallback((discountCode: string) => {
setState((prev) => ({ ...prev, discountCode }))
}, [])
const setPlacedOrder = useCallback((placedOrder: Order | null) => {
setState((prev) => ({ ...prev, placedOrder }))
}, [])
const resetCheckout = useCallback(() => {
setState(defaultState)
}, [])
const value = useMemo<CheckoutContextValue>(
() => ({
...state,
setDeliveryMode,
setSelectedAddressId,
setDiscountCode,
setPlacedOrder,
resetCheckout,
}),
[state, setDeliveryMode, setSelectedAddressId, setDiscountCode, setPlacedOrder, resetCheckout],
)
return <CheckoutContext.Provider value={value}>{children}</CheckoutContext.Provider>
}
export function useCheckout() {
const ctx = useContext(CheckoutContext)
if (!ctx) {
throw new Error('useCheckout must be used within CheckoutProvider')
}
return ctx
}
@@ -0,0 +1,34 @@
import { useEffect, type ReactNode } from 'react'
import { isAbortError } from '../lib/api'
import { getTenantDomain } from '../lib/config'
import { resolveTenantByDomain } from '../services/tenantService'
import { applyBusinessPrimaryColor, resetBusinessPrimaryColor } from '../utils/applyBusinessTheme'
import { normalizeBusinessPrimaryColorId } from '../utils/businessPrimaryColors'
export function CustomerThemeProvider({ children }: { children: ReactNode }) {
useEffect(() => {
const controller = new AbortController()
const domain = getTenantDomain()
async function loadTheme() {
try {
const tenant = await resolveTenantByDomain(domain, controller.signal)
applyBusinessPrimaryColor(
normalizeBusinessPrimaryColorId(tenant.primaryColor),
)
} catch (err) {
if (isAbortError(err)) return
applyBusinessPrimaryColor(undefined)
}
}
void loadTheme()
return () => {
controller.abort()
resetBusinessPrimaryColor()
}
}, [])
return children
}
@@ -0,0 +1,98 @@
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react'
import { applyDocumentFavicon } from '@meshkee/dashboard-core'
import { isAbortError } from '../lib/api'
import { getTenantDomain } from '../lib/config'
import { getWebsiteBusinessInfo } from '../services/websiteService'
import { resolveTenantByDomain } from '../services/tenantService'
interface TenantBrandingContextValue {
businessName: string
faviconUrl: string | null
}
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 domain = getTenantDomain()
useEffect(() => {
const controller = new AbortController()
async function loadBranding() {
try {
const [tenant, info] = await Promise.all([
resolveTenantByDomain(domain, controller.signal),
getWebsiteBusinessInfo(domain, controller.signal).catch(() => null),
])
if (controller.signal.aborted) return
const name = pickBusinessName(
info?.nameFa,
info?.name,
tenant.nameFa,
tenant.name,
domain,
)
const nextFavicon =
info?.faviconUrl?.trim() ||
tenant.faviconUrl?.trim() ||
info?.logoUrl?.trim() ||
tenant.logoUrl?.trim() ||
null
setBusinessName(name || domain)
setFaviconUrl(nextFavicon)
applyDocumentFavicon(nextFavicon)
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return
setBusinessName(domain)
setFaviconUrl(null)
applyDocumentFavicon(null)
}
}
void loadBranding()
return () => {
controller.abort()
}
}, [domain])
const value = useMemo(
() => ({ businessName, faviconUrl }),
[businessName, faviconUrl],
)
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
}