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,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
|
||||
}
|
||||
Reference in New Issue
Block a user