mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-12 06:40:57 +04:30
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,122 @@
|
||||
import { API_BASE_URL } from './config'
|
||||
|
||||
const ACCESS_TOKEN_KEY = 'meshkee_business_access_token'
|
||||
const REFRESH_TOKEN_KEY = 'meshkee_business_refresh_token'
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
export function isAbortError(err: unknown): boolean {
|
||||
return err instanceof DOMException && err.name === 'AbortError'
|
||||
}
|
||||
|
||||
export function getAccessToken() {
|
||||
return localStorage.getItem(ACCESS_TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function getRefreshToken() {
|
||||
return localStorage.getItem(REFRESH_TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function setTokens(accessToken: string, refreshToken: string) {
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken)
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken)
|
||||
}
|
||||
|
||||
export function clearTokens() {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY)
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||
}
|
||||
|
||||
function parseErrorMessage(payload: unknown, fallback: string) {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const message = (payload as { message?: string | string[] }).message
|
||||
|
||||
if (Array.isArray(message)) {
|
||||
return message.join(', ')
|
||||
}
|
||||
|
||||
if (typeof message === 'string') {
|
||||
return message
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
async function refreshAccessToken() {
|
||||
const refreshToken = getRefreshToken()
|
||||
if (!refreshToken) {
|
||||
return false
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
clearTokens()
|
||||
return false
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setTokens(data.accessToken, data.refreshToken)
|
||||
return true
|
||||
}
|
||||
|
||||
interface RequestOptions extends Omit<RequestInit, 'body'> {
|
||||
body?: unknown
|
||||
auth?: boolean
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { body, auth = false, headers, ...rest } = options
|
||||
|
||||
const requestHeaders = new Headers(headers)
|
||||
|
||||
if (body !== undefined) {
|
||||
requestHeaders.set('Content-Type', 'application/json')
|
||||
}
|
||||
|
||||
if (auth) {
|
||||
const accessToken = getAccessToken()
|
||||
if (accessToken) {
|
||||
requestHeaders.set('Authorization', `Bearer ${accessToken}`)
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...rest,
|
||||
headers: requestHeaders,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
if (response.status === 401 && auth) {
|
||||
const refreshed = await refreshAccessToken()
|
||||
if (refreshed) {
|
||||
return apiRequest<T>(path, options)
|
||||
}
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => null)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(
|
||||
parseErrorMessage(payload, `Request failed with status ${response.status}`),
|
||||
response.status,
|
||||
)
|
||||
}
|
||||
|
||||
return payload as T
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { AuthUser } from '../types/auth'
|
||||
import { getBusinessDomain } from './config'
|
||||
|
||||
const ACTIVE_BUSINESS_ID_KEY = 'meshkee_active_business_id'
|
||||
const ACTIVE_BUSINESS_DOMAIN_KEY = 'meshkee_active_business_domain'
|
||||
|
||||
export function setActiveBusiness(user: AuthUser) {
|
||||
const domain = getBusinessDomain()
|
||||
localStorage.setItem(ACTIVE_BUSINESS_DOMAIN_KEY, domain)
|
||||
|
||||
if (user.businesses.length > 0) {
|
||||
localStorage.setItem(ACTIVE_BUSINESS_ID_KEY, String(user.businesses[0].id))
|
||||
}
|
||||
}
|
||||
|
||||
export function setActiveBusinessById(businessId: string, domain?: string) {
|
||||
localStorage.setItem(ACTIVE_BUSINESS_ID_KEY, businessId)
|
||||
localStorage.setItem(ACTIVE_BUSINESS_DOMAIN_KEY, domain ?? getBusinessDomain())
|
||||
}
|
||||
|
||||
export function getActiveBusinessId(): string | null {
|
||||
return localStorage.getItem(ACTIVE_BUSINESS_ID_KEY)
|
||||
}
|
||||
|
||||
export function getActiveBusinessDomain(): string {
|
||||
return localStorage.getItem(ACTIVE_BUSINESS_DOMAIN_KEY) ?? getBusinessDomain()
|
||||
}
|
||||
|
||||
export function clearActiveBusiness() {
|
||||
localStorage.removeItem(ACTIVE_BUSINESS_ID_KEY)
|
||||
localStorage.removeItem(ACTIVE_BUSINESS_DOMAIN_KEY)
|
||||
}
|
||||
|
||||
export const BUSINESS_PROFILE_UPDATED_EVENT = 'meshkee:business-profile-updated'
|
||||
|
||||
export function dispatchBusinessProfileUpdated() {
|
||||
window.dispatchEvent(new Event(BUSINESS_PROFILE_UPDATED_EVENT))
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Normalize Iranian/local input to E.164 (e.g. +989121111111). */
|
||||
export function toE164CellNumber(input: string): string {
|
||||
const digits = input.replace(/\D/g, '')
|
||||
|
||||
if (!digits) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (digits.startsWith('98')) {
|
||||
return `+${digits}`
|
||||
}
|
||||
|
||||
if (digits.startsWith('0')) {
|
||||
return `+98${digits.slice(1)}`
|
||||
}
|
||||
|
||||
if (digits.length === 10 && digits.startsWith('9')) {
|
||||
return `+98${digits}`
|
||||
}
|
||||
|
||||
return `+${digits}`
|
||||
}
|
||||
|
||||
/** Display E.164 Iranian numbers as local format (e.g. 0912...). */
|
||||
export function formatCellForDisplay(cellNumber: string): string {
|
||||
if (cellNumber.startsWith('+98')) {
|
||||
return `0${cellNumber.slice(3)}`
|
||||
}
|
||||
return cellNumber
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
BUSINESS_SUBDOMAIN_PREFIX,
|
||||
getBaseDomainFromHost,
|
||||
getBusinessDashboardHost,
|
||||
isBusinessDashboardHost,
|
||||
} from '@meshkee/dashboard-core'
|
||||
|
||||
export const API_BASE_URL =
|
||||
import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000/api/v1'
|
||||
|
||||
export { BUSINESS_SUBDOMAIN_PREFIX }
|
||||
|
||||
/** Base tenant domain without dashboard prefix (e.g. sanihome.ir). */
|
||||
export function getBaseBusinessDomain(hostname = window.location.hostname): string {
|
||||
return getBaseDomainFromHost(hostname, import.meta.env.VITE_BUSINESS_DOMAIN)
|
||||
}
|
||||
|
||||
/** Expected business admin dashboard host (e.g. business.sanihome.ir). */
|
||||
export function getBusinessDashboardHostForApp(baseDomain?: string): string {
|
||||
return getBusinessDashboardHost(baseDomain ?? getBaseBusinessDomain())
|
||||
}
|
||||
|
||||
export function isAllowedBusinessHost(hostname = window.location.hostname): boolean {
|
||||
const host = hostname.toLowerCase().trim()
|
||||
|
||||
if (host === 'localhost' || host === '127.0.0.1') {
|
||||
return true
|
||||
}
|
||||
|
||||
return isBusinessDashboardHost(host)
|
||||
}
|
||||
|
||||
/** Domain sent to tenant resolution APIs. */
|
||||
export function getBusinessDomain(): string {
|
||||
return getBaseBusinessDomain()
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { RouteTitleRule } from '@meshkee/dashboard-core'
|
||||
|
||||
export const BUSINESS_DASHBOARD_NAME = 'Business Dashboard'
|
||||
|
||||
export const businessRouteTitleRules: RouteTitleRule[] = [
|
||||
{ match: '/login', labels: ['Sign in'] },
|
||||
{ match: '/business-profile', labels: ['Business Profile'] },
|
||||
{ match: '/products/categories', labels: ['Products', 'Categories'] },
|
||||
{ match: '/products/brands', labels: ['Products', 'Brands'] },
|
||||
{ match: '/products/new', labels: ['Products', 'Add New Product'] },
|
||||
{ match: /^\/products\/edit\/[^/]+$/, labels: ['Products', 'Edit Product'] },
|
||||
{ match: '/products/list', labels: ['Products', 'My Products'] },
|
||||
{ match: /^\/products\/detail\/[^/]+$/, labels: ['Products', 'Product Details'] },
|
||||
{ match: '/products/settings', labels: ['Products', 'Settings'] },
|
||||
{ match: '/products', labels: ['Products'] },
|
||||
{ match: '/store/items', labels: ['Store', 'My Store Items'] },
|
||||
{ match: '/store/orders', labels: ['Store', 'My Orders'] },
|
||||
{ match: '/store/cards', labels: ['Store', 'Shopping Cards'] },
|
||||
{ match: '/store/settings', labels: ['Store', 'Settings'] },
|
||||
{ match: '/store', labels: ['Store'] },
|
||||
{ match: '/customers', labels: ['Customers'] },
|
||||
{ match: '/blog/list', labels: ['Blog', 'My Blogs'] },
|
||||
{ match: /^\/blog\/detail\/[^/]+$/, labels: ['Blog', 'Blog Details'] },
|
||||
{ match: '/blog/new', labels: ['Blog', 'Add New Blog'] },
|
||||
{ match: /^\/blog\/edit\/[^/]+$/, labels: ['Blog', 'Edit Blog'] },
|
||||
{ match: '/blog/categories', labels: ['Blog', 'Categories'] },
|
||||
{ match: '/blog/settings', labels: ['Blog', 'Settings'] },
|
||||
{ match: '/blog', labels: ['Blog'] },
|
||||
{ match: '/portfolios/list', labels: ['Portfolios', 'My Portfolios'] },
|
||||
{ match: /^\/portfolios\/detail\/[^/]+$/, labels: ['Portfolios', 'Portfolio Details'] },
|
||||
{ match: '/portfolios/new', labels: ['Portfolios', 'Add New Portfolio'] },
|
||||
{ match: /^\/portfolios\/edit\/[^/]+$/, labels: ['Portfolios', 'Edit Portfolio'] },
|
||||
{ match: '/portfolios/categories', labels: ['Portfolios', 'Categories'] },
|
||||
{ match: '/portfolios/settings', labels: ['Portfolios', 'Settings'] },
|
||||
{ match: '/portfolios', labels: ['Portfolios'] },
|
||||
{ match: '/website/sliders', labels: ['Website', 'Sliders'] },
|
||||
{ match: '/website/special-categories', labels: ['Website', 'Special Categories'] },
|
||||
{ match: '/website/special-brands', labels: ['Website', 'Special Brands'] },
|
||||
{ match: '/website/special-items', labels: ['Website', 'Special Items'] },
|
||||
{ match: '/website/contact', labels: ['Website', 'Contact Us Form'] },
|
||||
{ match: '/website/subscriptions', labels: ['Website', 'Subscriptions'] },
|
||||
{ match: '/website/faq', labels: ['Website', 'FAQ'] },
|
||||
{ match: '/website/badges', labels: ['Website', 'Badges'] },
|
||||
{ match: '/website/e-payment', labels: ['Website', 'E-Payment'] },
|
||||
{ match: '/website', labels: ['Website'] },
|
||||
{ match: '/', labels: ['Home'] },
|
||||
]
|
||||
Reference in New Issue
Block a user