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
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@meshkee/dashboard-core",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./styles/tokens.css": "./src/styles/tokens.css"
},
"scripts": {
"build": "tsc -p tsconfig.json --noEmit",
"lint": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"typescript": "~6.0.2"
}
}
@@ -0,0 +1,143 @@
export interface ApiClientConfig {
baseUrl: string
accessTokenKey: string
refreshTokenKey: string
}
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'
}
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
}
export interface ApiClient {
getAccessToken: () => string | null
getRefreshToken: () => string | null
setTokens: (accessToken: string, refreshToken: string) => void
clearTokens: () => void
apiRequest: <T>(path: string, options?: ApiRequestOptions) => Promise<T>
}
interface ApiRequestOptions extends Omit<RequestInit, 'body'> {
body?: unknown
auth?: boolean
}
export function createApiClient(config: ApiClientConfig): ApiClient {
const { baseUrl, accessTokenKey, refreshTokenKey } = config
function getAccessToken() {
return localStorage.getItem(accessTokenKey)
}
function getRefreshToken() {
return localStorage.getItem(refreshTokenKey)
}
function setTokens(accessToken: string, refreshToken: string) {
localStorage.setItem(accessTokenKey, accessToken)
localStorage.setItem(refreshTokenKey, refreshToken)
}
function clearTokens() {
localStorage.removeItem(accessTokenKey)
localStorage.removeItem(refreshTokenKey)
}
async function refreshAccessToken() {
const refreshToken = getRefreshToken()
if (!refreshToken) {
return false
}
const response = await fetch(`${baseUrl}/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
}
async function apiRequest<T>(path: string, options: ApiRequestOptions = {}): 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(`${baseUrl}${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
}
return {
getAccessToken,
getRefreshToken,
setTokens,
clearTokens,
apiRequest,
}
}
@@ -0,0 +1,57 @@
export const CUSTOMER_SUBDOMAIN_PREFIX = 'customer.'
export const BUSINESS_SUBDOMAIN_PREFIX = 'business.'
const DASHBOARD_PREFIXES = [CUSTOMER_SUBDOMAIN_PREFIX, BUSINESS_SUBDOMAIN_PREFIX] as const
export function stripDashboardSubdomain(hostname: string): string {
const host = hostname.toLowerCase().trim()
for (const prefix of DASHBOARD_PREFIXES) {
if (host.startsWith(prefix)) {
return host.slice(prefix.length)
}
}
return host
}
function isDashboardSubdomainHost(hostname: string): boolean {
const host = hostname.toLowerCase().trim()
return DASHBOARD_PREFIXES.some((prefix) => host.startsWith(prefix))
}
export function isBusinessDashboardHost(hostname: string): boolean {
return hostname.toLowerCase().trim().startsWith(BUSINESS_SUBDOMAIN_PREFIX)
}
export function isCustomerDashboardHost(hostname: string): boolean {
return hostname.toLowerCase().trim().startsWith(CUSTOMER_SUBDOMAIN_PREFIX)
}
/** Resolve tenant base domain (e.g. sanihome.ir) from the current host. */
export function getBaseDomainFromHost(
hostname: string,
envBaseDomain?: string,
): string {
const host = hostname.toLowerCase().trim()
// On business.* / customer.* the hostname always wins — one build serves all tenants.
if (isDashboardSubdomainHost(host)) {
return stripDashboardSubdomain(host)
}
// localhost fallback for dev without /etc/hosts subdomains
if (envBaseDomain?.trim()) {
return envBaseDomain.trim()
}
return stripDashboardSubdomain(host)
}
export function getCustomerDashboardHost(baseDomain: string): string {
return `${CUSTOMER_SUBDOMAIN_PREFIX}${baseDomain}`
}
export function getBusinessDashboardHost(baseDomain: string): string {
return `${BUSINESS_SUBDOMAIN_PREFIX}${baseDomain}`
}
+43
View File
@@ -0,0 +1,43 @@
export type { ApiClient, ApiClientConfig } from './api/createApiClient'
export { ApiError, createApiClient, isAbortError } from './api/createApiClient'
export type {
AuthUser,
DashboardType,
LoginResponse,
MeResponse,
OtpSendResponse,
OtpVerifyResponse,
RegisterResponse,
UserProfile,
} from './types/auth'
export { toE164CellNumber, formatCellForDisplay } from './utils/cellNumber'
export {
calcDiscountPercent,
formatIrtInput,
formatIrtPrice,
hasStoreItemDiscount,
parseIrtInput,
} from './utils/irtPrice'
export {
formatDashboardDocumentTitle,
normalizeRoutePath,
resolveRoutePageLabels,
type DashboardDocumentTitleParts,
type RouteTitleRule,
} from './utils/documentTitle'
export { applyDocumentFavicon } from './utils/favicon'
export {
BUSINESS_SUBDOMAIN_PREFIX,
CUSTOMER_SUBDOMAIN_PREFIX,
getBaseDomainFromHost,
getBusinessDashboardHost,
getCustomerDashboardHost,
isBusinessDashboardHost,
isCustomerDashboardHost,
stripDashboardSubdomain,
} from './domain/subdomain'
@@ -0,0 +1,159 @@
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--primary: #3b82f6;
--primary-glow: #3b82f6;
--primary-light: #dbeafe;
--primary-dark: #2563eb;
--primary-rgb: 59 130 246;
--primary-dark-rgb: 37 99 235;
--bg-gradient-start: color-mix(in srgb, var(--primary-light) 72%, #ffffff);
--bg-gradient-mid: color-mix(in srgb, var(--primary-light) 42%, #ffffff);
--bg-gradient-end: color-mix(in srgb, var(--primary-light) 18%, #ffffff);
--glass-bg: rgba(255, 255, 255, 0.55);
--glass-border: rgba(255, 255, 255, 0.7);
--glass-shadow: 0 8px 32px rgba(31, 38, 135, 0.08);
--blur-glass: 28px;
--text-primary: #1e293b;
--text-secondary: #64748b;
--text-muted: #94a3b8;
--border-color: rgba(148, 163, 184, 0.35);
--surface: rgba(255, 255, 255, 0.7);
--sidebar-width: 260px;
--radius: 16px;
--radius-sm: 12px;
--select-arrow-size: 16px;
--select-arrow-offset: 12px;
--select-padding-end: 2.5rem;
--field-font-size: 13px;
--field-padding-y: 9px;
--field-padding-x: 12px;
--field-height: 38px;
--font-en: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-fa: 'IRANYekan', 'IranYekan', 'Yekan', Tahoma, sans-serif;
--card-hover-lift: -4px;
--card-hover-shadow: 0 16px 48px rgba(var(--primary-rgb) / 0.14);
--card-hover-transition: transform 0.25s ease, box-shadow 0.25s ease;
}
html {
min-height: 100%;
}
body,
#root {
min-height: 100%;
}
body {
font-family: var(--font-en);
color: var(--text-primary);
background-color: var(--bg-gradient-mid);
background-image:
radial-gradient(ellipse 520px 520px at calc(100% - 40px) -60px, rgba(var(--primary-rgb) / 0.28), transparent 72%),
radial-gradient(ellipse 420px 420px at 18% calc(100% + 20px), rgba(var(--primary-rgb) / 0.18), transparent 72%),
radial-gradient(ellipse 320px 320px at -40px 42%, rgba(var(--primary-rgb) / 0.12), transparent 72%),
linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-mid) 50%, var(--bg-gradient-end) 100%);
background-attachment: fixed;
background-repeat: no-repeat;
background-size: cover;
-webkit-font-smoothing: antialiased;
}
/* Interactive glass cards: lift + shadow on hover (no border change) */
[data-card-hover] {
transition: var(--card-hover-transition);
}
[data-card-hover]:hover,
[data-card-hover][data-card-hover-active='true'] {
transform: translateY(var(--card-hover-lift));
box-shadow: var(--card-hover-shadow);
}
button {
font-family: inherit;
cursor: pointer;
border: none;
background: none;
}
a {
text-decoration: none;
color: inherit;
}
select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
font-family: inherit;
font-size: var(--field-font-size);
line-height: 1.4;
color: var(--text-primary);
background-color: rgba(255, 255, 255, 0.7);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
min-height: var(--field-height);
padding: var(--field-padding-y) var(--select-padding-end) var(--field-padding-y) var(--field-padding-x);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right var(--select-arrow-offset) center;
background-size: var(--select-arrow-size);
cursor: pointer;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box;
}
select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
input:not([type='checkbox']):not([type='radio']):not([type='file']):not([type='range']):not([type='hidden']),
textarea {
font-family: var(--font-fa), var(--font-en);
font-size: var(--field-font-size);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
:where(input:not([type='checkbox']):not([type='radio']):not([type='file']):not([type='range']):not([type='hidden'])) {
min-height: var(--field-height);
padding: var(--field-padding-y) var(--field-padding-x);
line-height: 1.4;
box-sizing: border-box;
}
input:not([type='checkbox']):not([type='radio']):not([type='file']):not([type='range']):not([type='hidden']):focus,
textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
input:not([type='checkbox']):not([type='radio']):not([type='file']):not([type='range']):not([type='hidden'])::placeholder,
textarea::placeholder {
font-family: var(--font-fa), var(--font-en);
}
[dir='rtl'],
:lang(fa),
.faText {
font-family: var(--font-fa), var(--font-en);
font-weight: 400;
text-align: right;
}
[dir='rtl']::placeholder,
:lang(fa)::placeholder,
.faText::placeholder {
font-family: var(--font-fa), var(--font-en);
font-weight: 400;
}
+65
View File
@@ -0,0 +1,65 @@
export type DashboardType = 'super_admin' | 'business' | 'customer'
export interface UserProfile {
about: string
city: string
address: string
landline: string
backupPhone: string
postalCode: string
instagram: string
telegramId: string
linkedin: string
}
export interface AuthUser {
id: string
cellNumber: string
email: string | null
firstName: string | null
lastName: string | null
cellVerifiedAt: string | null
roles: string[]
dashboard: DashboardType
primaryRole?: string
roleLabel?: string
isSuperAdmin?: boolean
profile: UserProfile
businesses: {
id: string
name: string
slug: string
isOwner: boolean
teamRole: string | null
permissions: string[]
}[]
customerBusinesses: { id: string; name: string; slug: string }[]
}
export interface LoginResponse {
message: string
user: AuthUser
accessToken: string
refreshToken: string
}
export interface MeResponse {
user: AuthUser
}
export interface RegisterResponse extends LoginResponse {
smsEnabled?: boolean
registeredBusiness?: { id: string; name: string; slug: string }
}
export interface OtpSendResponse {
enabled: boolean
message: string
expiresInSeconds?: number
}
export interface OtpVerifyResponse {
enabled: boolean
verified: boolean
message: string
}
@@ -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,54 @@
export interface DashboardDocumentTitleParts {
businessName: string
dashboardName: string
pageLabels?: string[]
}
/** Pattern: `{businessName} - {dashboardName} · {page}` */
export function formatDashboardDocumentTitle({
businessName,
dashboardName,
pageLabels = [],
}: DashboardDocumentTitleParts): string {
const business = businessName.trim() || 'Store'
const dashboard = dashboardName.trim()
const pages = pageLabels.map((label) => label.trim()).filter(Boolean)
if (pages.length === 0) {
return `${business} - ${dashboard}`
}
return `${business} - ${dashboard} · ${pages.join(' · ')}`
}
export interface RouteTitleRule {
match: string | RegExp
labels: string[]
}
export function normalizeRoutePath(pathname: string): string {
const path = pathname.split('?')[0]?.split('#')[0] ?? '/'
if (path === '/') return '/'
return path.replace(/\/+$/, '') || '/'
}
export function resolveRoutePageLabels(pathname: string, rules: RouteTitleRule[]): string[] {
const path = normalizeRoutePath(pathname)
const sorted = [...rules].sort((a, b) => {
const lenA = typeof a.match === 'string' ? a.match.length : 0
const lenB = typeof b.match === 'string' ? b.match.length : 0
return lenB - lenA
})
for (const rule of sorted) {
if (typeof rule.match === 'string' && rule.match === path) {
return rule.labels
}
if (rule.match instanceof RegExp && rule.match.test(path)) {
return rule.labels
}
}
return []
}
@@ -0,0 +1,35 @@
const FAVICON_ATTR = 'data-business-favicon'
function removeIconLinks(root: ParentNode = document.head) {
root
.querySelectorAll(
'link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]',
)
.forEach((node) => node.remove())
}
/** Sets browser tab favicon links for the current tenant. Pass null to clear. */
export function applyDocumentFavicon(url: string | null | undefined) {
if (typeof document === 'undefined') return
removeIconLinks()
const href = url?.trim()
if (!href) return
// Bust browser favicon cache when the logo/favicon media URL changes.
const cacheBusted =
href.includes('?') ? `${href}&v=${Date.now()}` : `${href}?v=${Date.now()}`
for (const rel of ['icon', 'apple-touch-icon'] as const) {
const link = document.createElement('link')
link.setAttribute(FAVICON_ATTR, 'true')
link.rel = rel
link.href = cacheBusted
if (rel === 'icon') {
link.type = 'image/png'
link.sizes = '48x48'
}
document.head.appendChild(link)
}
}
@@ -0,0 +1,36 @@
/** Format a numeric price for display in Iranian Toman (IRT). */
export function formatIrtPrice(value: number | null | undefined): string {
if (value === null || value === undefined) return '—'
return `${Math.round(value).toLocaleString('en-US')} IRT`
}
/** Format raw digits into comma-separated groups while typing. */
export function formatIrtInput(raw: string): string {
const digits = raw.replace(/\D/g, '')
if (!digits) return ''
return Number(digits).toLocaleString('en-US')
}
/** Parse a formatted IRT input string back to a number. */
export function parseIrtInput(formatted: string): number | null {
const digits = formatted.replace(/\D/g, '')
if (!digits) return null
return Number(digits)
}
export function hasStoreItemDiscount(
price: number | null,
discountedPrice: number | null,
): boolean {
return (
price !== null &&
discountedPrice !== null &&
discountedPrice < price &&
discountedPrice >= 0
)
}
export function calcDiscountPercent(price: number, discountedPrice: number): number {
if (price <= 0 || discountedPrice >= price) return 0
return Math.round((1 - discountedPrice / price) * 100)
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"verbatimModuleSyntax": true
},
"include": ["src"]
}