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