mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +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,34 @@
|
||||
import {
|
||||
DEFAULT_BUSINESS_PRIMARY_COLOR_ID,
|
||||
getBusinessPrimaryColorTokens,
|
||||
type BusinessPrimaryColorId,
|
||||
} from './businessPrimaryColors'
|
||||
|
||||
const CSS_VAR_DEFAULTS: Record<string, string> = {
|
||||
'--primary': '#3b82f6',
|
||||
'--primary-glow': '#3b82f6',
|
||||
'--primary-light': '#dbeafe',
|
||||
'--primary-dark': '#2563eb',
|
||||
'--primary-rgb': '59 130 246',
|
||||
'--primary-dark-rgb': '37 99 235',
|
||||
}
|
||||
|
||||
export function applyBusinessPrimaryColor(colorId?: BusinessPrimaryColorId | null) {
|
||||
const root = document.documentElement
|
||||
const tokens = getBusinessPrimaryColorTokens(colorId ?? DEFAULT_BUSINESS_PRIMARY_COLOR_ID)
|
||||
|
||||
root.style.setProperty('--primary', tokens.primary)
|
||||
root.style.setProperty('--primary-glow', tokens.primaryGlow)
|
||||
root.style.setProperty('--primary-light', tokens.primaryLight)
|
||||
root.style.setProperty('--primary-dark', tokens.primaryDark)
|
||||
root.style.setProperty('--primary-rgb', tokens.primaryRgb)
|
||||
root.style.setProperty('--primary-dark-rgb', tokens.primaryDarkRgb)
|
||||
}
|
||||
|
||||
export function resetBusinessPrimaryColor() {
|
||||
const root = document.documentElement
|
||||
|
||||
for (const [name, value] of Object.entries(CSS_VAR_DEFAULTS)) {
|
||||
root.style.setProperty(name, value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { MultiSelectOption } from '../components/MultiSelectDropdown'
|
||||
import type { BusinessActivityCategory } from '../services/businessActivityCategoryService'
|
||||
|
||||
export function flattenBusinessActivityCategories(
|
||||
categories: BusinessActivityCategory[],
|
||||
): MultiSelectOption<string>[] {
|
||||
const result: MultiSelectOption<string>[] = []
|
||||
|
||||
function walk(parentId: string | null, depth: number) {
|
||||
const children = categories
|
||||
.filter((category) => category.parentId === parentId)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))
|
||||
|
||||
for (const child of children) {
|
||||
result.push({ value: child.id, label: child.name, depth })
|
||||
walk(child.id, depth + 1)
|
||||
}
|
||||
}
|
||||
|
||||
walk(null, 0)
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
export const BUSINESS_PRIMARY_COLOR_IDS = [
|
||||
'red',
|
||||
'yellow',
|
||||
'black',
|
||||
'cyan',
|
||||
'purple',
|
||||
'light-blue',
|
||||
'dark-blue',
|
||||
] as const
|
||||
|
||||
export type BusinessPrimaryColorId = (typeof BUSINESS_PRIMARY_COLOR_IDS)[number]
|
||||
|
||||
export const DEFAULT_BUSINESS_PRIMARY_COLOR_ID: BusinessPrimaryColorId = 'dark-blue'
|
||||
|
||||
export type BusinessPrimaryColorTokens = {
|
||||
label: string
|
||||
primary: string
|
||||
primaryDark: string
|
||||
primaryLight: string
|
||||
primaryGlow: string
|
||||
primaryRgb: string
|
||||
primaryDarkRgb: string
|
||||
}
|
||||
|
||||
export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
|
||||
BusinessPrimaryColorId,
|
||||
BusinessPrimaryColorTokens
|
||||
> = {
|
||||
red: {
|
||||
label: 'Red',
|
||||
primary: '#ef4444',
|
||||
primaryDark: '#dc2626',
|
||||
primaryLight: '#fee2e2',
|
||||
primaryGlow: '#ef4444',
|
||||
primaryRgb: '239 68 68',
|
||||
primaryDarkRgb: '220 38 38',
|
||||
},
|
||||
yellow: {
|
||||
label: 'Yellow',
|
||||
primary: '#eab308',
|
||||
primaryDark: '#ca8a04',
|
||||
primaryLight: '#fef9c3',
|
||||
primaryGlow: '#eab308',
|
||||
primaryRgb: '234 179 8',
|
||||
primaryDarkRgb: '202 138 4',
|
||||
},
|
||||
black: {
|
||||
label: 'Black',
|
||||
primary: '#1e293b',
|
||||
primaryDark: '#0f172a',
|
||||
primaryLight: '#e2e8f0',
|
||||
primaryGlow: '#334155',
|
||||
primaryRgb: '30 41 59',
|
||||
primaryDarkRgb: '15 23 42',
|
||||
},
|
||||
cyan: {
|
||||
label: 'Cyan',
|
||||
primary: '#06b6d4',
|
||||
primaryDark: '#0891b2',
|
||||
primaryLight: '#cffafe',
|
||||
primaryGlow: '#06b6d4',
|
||||
primaryRgb: '6 182 212',
|
||||
primaryDarkRgb: '8 145 178',
|
||||
},
|
||||
purple: {
|
||||
label: 'Purple',
|
||||
primary: '#a855f7',
|
||||
primaryDark: '#9333ea',
|
||||
primaryLight: '#f3e8ff',
|
||||
primaryGlow: '#a855f7',
|
||||
primaryRgb: '168 85 247',
|
||||
primaryDarkRgb: '147 51 234',
|
||||
},
|
||||
'light-blue': {
|
||||
label: 'Light Blue',
|
||||
primary: '#38bdf8',
|
||||
primaryDark: '#0ea5e9',
|
||||
primaryLight: '#e0f2fe',
|
||||
primaryGlow: '#38bdf8',
|
||||
primaryRgb: '56 189 248',
|
||||
primaryDarkRgb: '14 165 233',
|
||||
},
|
||||
'dark-blue': {
|
||||
label: 'Dark Blue',
|
||||
primary: '#3b82f6',
|
||||
primaryDark: '#2563eb',
|
||||
primaryLight: '#dbeafe',
|
||||
primaryGlow: '#3b82f6',
|
||||
primaryRgb: '59 130 246',
|
||||
primaryDarkRgb: '37 99 235',
|
||||
},
|
||||
}
|
||||
|
||||
export function normalizeBusinessPrimaryColorId(value: unknown): BusinessPrimaryColorId {
|
||||
if (
|
||||
typeof value === 'string' &&
|
||||
BUSINESS_PRIMARY_COLOR_IDS.includes(value as BusinessPrimaryColorId)
|
||||
) {
|
||||
return value as BusinessPrimaryColorId
|
||||
}
|
||||
|
||||
return DEFAULT_BUSINESS_PRIMARY_COLOR_ID
|
||||
}
|
||||
|
||||
export function getBusinessPrimaryColorTokens(
|
||||
colorId: BusinessPrimaryColorId | undefined | null,
|
||||
): BusinessPrimaryColorTokens {
|
||||
return BUSINESS_PRIMARY_COLOR_PALETTE[
|
||||
normalizeBusinessPrimaryColorId(colorId ?? DEFAULT_BUSINESS_PRIMARY_COLOR_ID)
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Category, FlatCategory } from '../types/category'
|
||||
|
||||
export function flattenCategories(categories: Category[]): FlatCategory[] {
|
||||
const result: FlatCategory[] = []
|
||||
|
||||
function walk(parentId: string | null, depth: number) {
|
||||
const children = categories
|
||||
.filter((c) => c.parentId === parentId)
|
||||
.sort((a, b) => a.nameEn.localeCompare(b.nameEn))
|
||||
|
||||
for (const child of children) {
|
||||
result.push({ ...child, depth })
|
||||
walk(child.id, depth + 1)
|
||||
}
|
||||
}
|
||||
|
||||
walk(null, 0)
|
||||
return result
|
||||
}
|
||||
|
||||
export function getDescendantIds(categories: Category[], id: string): string[] {
|
||||
const ids: string[] = [id]
|
||||
const children = categories.filter((c) => c.parentId === id)
|
||||
for (const child of children) {
|
||||
ids.push(...getDescendantIds(categories, child.id))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
export function getChildren(categories: Category[], parentId: string | null): Category[] {
|
||||
return categories
|
||||
.filter((c) => c.parentId === parentId)
|
||||
.sort((a, b) => a.nameEn.localeCompare(b.nameEn))
|
||||
}
|
||||
|
||||
export function hasChildren(categories: Category[], id: string): boolean {
|
||||
return categories.some((c) => c.parentId === id)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Area } from 'react-easy-crop'
|
||||
|
||||
function createImage(url: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image()
|
||||
image.addEventListener('load', () => resolve(image))
|
||||
image.addEventListener('error', reject)
|
||||
image.src = url
|
||||
})
|
||||
}
|
||||
|
||||
export async function getCroppedImage(
|
||||
imageSrc: string,
|
||||
pixelCrop: Area,
|
||||
format: 'jpeg' | 'png' = 'jpeg',
|
||||
): Promise<string> {
|
||||
const image = await createImage(imageSrc)
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('Could not get canvas context')
|
||||
|
||||
canvas.width = pixelCrop.width
|
||||
canvas.height = pixelCrop.height
|
||||
|
||||
if (format === 'png') {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
}
|
||||
|
||||
ctx.drawImage(
|
||||
image,
|
||||
pixelCrop.x,
|
||||
pixelCrop.y,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height,
|
||||
0,
|
||||
0,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height,
|
||||
)
|
||||
|
||||
if (format === 'png') {
|
||||
return canvas.toDataURL('image/png')
|
||||
}
|
||||
|
||||
return canvas.toDataURL('image/jpeg', 0.92)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { hasStoreItemDiscount } from './irtPrice'
|
||||
import type { StoreProductListing } from './storeProductGroups'
|
||||
|
||||
export interface StoreListingFilters {
|
||||
name: string
|
||||
minPrice: number | null
|
||||
maxPrice: number | null
|
||||
onlyDiscounted: boolean
|
||||
}
|
||||
|
||||
export const EMPTY_STORE_LISTING_FILTERS: StoreListingFilters = {
|
||||
name: '',
|
||||
minPrice: null,
|
||||
maxPrice: null,
|
||||
onlyDiscounted: false,
|
||||
}
|
||||
|
||||
function listingEffectivePrice(listing: StoreProductListing): number | null {
|
||||
if (hasStoreItemDiscount(listing.displayPrice, listing.displayDiscountedPrice)) {
|
||||
return listing.displayDiscountedPrice
|
||||
}
|
||||
return listing.displayPrice
|
||||
}
|
||||
|
||||
export function filterStoreListings(
|
||||
listings: StoreProductListing[],
|
||||
filters: StoreListingFilters,
|
||||
): StoreProductListing[] {
|
||||
const nameQuery = filters.name.trim().toLowerCase()
|
||||
|
||||
return listings.filter((listing) => {
|
||||
if (nameQuery) {
|
||||
const matchesTitle = listing.productTitle.toLowerCase().includes(nameQuery)
|
||||
const matchesFa = listing.productNameFa?.toLowerCase().includes(nameQuery)
|
||||
if (!matchesTitle && !matchesFa) return false
|
||||
}
|
||||
|
||||
const price = listingEffectivePrice(listing)
|
||||
if (filters.minPrice !== null) {
|
||||
if (price === null || price < filters.minPrice) return false
|
||||
}
|
||||
if (filters.maxPrice !== null) {
|
||||
if (price === null || price > filters.maxPrice) return false
|
||||
}
|
||||
|
||||
if (filters.onlyDiscounted) {
|
||||
const hasDiscount = listing.variants.some((variant) =>
|
||||
hasStoreItemDiscount(variant.price, variant.discountedPrice),
|
||||
)
|
||||
if (!hasDiscount) return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function extractFirstImageUrl(html: string): string | null {
|
||||
if (!html || !html.includes('<img')) return null
|
||||
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html')
|
||||
const img = doc.querySelector('img[src]')
|
||||
const src = img?.getAttribute('src')?.trim()
|
||||
return src || null
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function createId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}`
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
const ALLOWED_IMAGE_TYPES = new Set([
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
])
|
||||
|
||||
function parseDataUrl(dataUrl: string): { mime: string; bytes: Uint8Array } {
|
||||
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/)
|
||||
if (!match) {
|
||||
throw new Error('Invalid image data')
|
||||
}
|
||||
|
||||
const mime = match[1] === 'image/jpg' ? 'image/jpeg' : match[1]
|
||||
const binary = atob(match[2])
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i)
|
||||
}
|
||||
|
||||
return { mime, bytes }
|
||||
}
|
||||
|
||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image()
|
||||
image.addEventListener('load', () => resolve(image))
|
||||
image.addEventListener('error', () => reject(new Error('Could not load image')))
|
||||
image.src = src
|
||||
})
|
||||
}
|
||||
|
||||
export function dataUrlToFile(dataUrl: string, filename: string): File {
|
||||
const { mime, bytes } = parseDataUrl(dataUrl)
|
||||
const copy = new Uint8Array(bytes)
|
||||
return new File([copy], filename, { type: mime })
|
||||
}
|
||||
|
||||
export async function ensureUploadFile(dataUrl: string, filename: string): Promise<File> {
|
||||
if (dataUrl.startsWith('data:')) {
|
||||
const mime = dataUrl.slice(5, dataUrl.indexOf(';'))
|
||||
const normalized = mime === 'image/jpg' ? 'image/jpeg' : mime
|
||||
|
||||
if (ALLOWED_IMAGE_TYPES.has(normalized)) {
|
||||
const file = dataUrlToFile(dataUrl, filename)
|
||||
if (file.size > 0) {
|
||||
return file
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const image = await loadImage(dataUrl)
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
throw new Error('Could not prepare image for upload')
|
||||
}
|
||||
|
||||
const wantsPng = filename.toLowerCase().endsWith('.png')
|
||||
canvas.width = image.naturalWidth
|
||||
canvas.height = image.naturalHeight
|
||||
|
||||
if (wantsPng) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
}
|
||||
|
||||
ctx.drawImage(image, 0, 0)
|
||||
|
||||
const outputDataUrl = wantsPng
|
||||
? canvas.toDataURL('image/png')
|
||||
: canvas.toDataURL('image/jpeg', 0.92)
|
||||
const safeName = filename.replace(/\.[^.]+$/, '') || 'image'
|
||||
const extension = wantsPng ? 'png' : 'jpg'
|
||||
return dataUrlToFile(outputDataUrl, `${safeName}.${extension}`)
|
||||
}
|
||||
|
||||
export async function ensureJpegUploadFile(
|
||||
dataUrl: string,
|
||||
filename: string,
|
||||
): Promise<File> {
|
||||
return ensureUploadFile(
|
||||
dataUrl,
|
||||
filename.toLowerCase().endsWith('.jpg') || filename.toLowerCase().endsWith('.jpeg')
|
||||
? filename
|
||||
: `${filename.replace(/\.[^.]+$/, '') || 'image'}.jpg`,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/** 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)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { ProductApi } from '../services/productService'
|
||||
|
||||
export interface ProductMonthActivity {
|
||||
monthKey: string
|
||||
label: string
|
||||
added: number
|
||||
updated: number
|
||||
}
|
||||
|
||||
function toMonthKey(iso: string): string {
|
||||
const date = new Date(iso)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
return `${year}-${month}`
|
||||
}
|
||||
|
||||
function formatMonthLabel(monthKey: string): string {
|
||||
const [year, month] = monthKey.split('-').map(Number)
|
||||
return new Date(year, month - 1, 1).toLocaleString('en-US', { month: 'short' })
|
||||
}
|
||||
|
||||
export function buildLast12MonthKeys(): string[] {
|
||||
const keys: string[] = []
|
||||
const now = new Date()
|
||||
|
||||
for (let i = 11; i >= 0; i -= 1) {
|
||||
const date = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
keys.push(`${year}-${month}`)
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
export function aggregateProductActivity(products: ProductApi[]): ProductMonthActivity[] {
|
||||
const monthKeys = buildLast12MonthKeys()
|
||||
const added = new Map(monthKeys.map((key) => [key, 0]))
|
||||
const updated = new Map(monthKeys.map((key) => [key, 0]))
|
||||
|
||||
for (const product of products) {
|
||||
const createdKey = toMonthKey(product.createdAt)
|
||||
if (added.has(createdKey)) {
|
||||
added.set(createdKey, (added.get(createdKey) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const createdTime = new Date(product.createdAt).getTime()
|
||||
const updatedTime = new Date(product.updatedAt).getTime()
|
||||
if (updatedTime > createdTime) {
|
||||
const updatedKey = toMonthKey(product.updatedAt)
|
||||
if (updated.has(updatedKey)) {
|
||||
updated.set(updatedKey, (updated.get(updatedKey) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return monthKeys.map((monthKey) => ({
|
||||
monthKey,
|
||||
label: formatMonthLabel(monthKey),
|
||||
added: added.get(monthKey) ?? 0,
|
||||
updated: updated.get(monthKey) ?? 0,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
export const STEP_COLOR_PRESETS = [
|
||||
'#EF4444',
|
||||
'#F97316',
|
||||
'#F59E0B',
|
||||
'#EAB308',
|
||||
'#84CC16',
|
||||
'#22C55E',
|
||||
'#10B981',
|
||||
'#14B8A6',
|
||||
'#06B6D4',
|
||||
'#0EA5E9',
|
||||
'#3B82F6',
|
||||
'#6366F1',
|
||||
'#8B5CF6',
|
||||
'#A855F7',
|
||||
'#D946EF',
|
||||
'#EC4899',
|
||||
'#F43F5E',
|
||||
'#78716C',
|
||||
'#6B7280',
|
||||
'#64748B',
|
||||
'#111827',
|
||||
'#92400E',
|
||||
'#1E3A5F',
|
||||
'#D4AF37',
|
||||
] as const
|
||||
|
||||
export type StepColorPreset = (typeof STEP_COLOR_PRESETS)[number]
|
||||
|
||||
export const DEFAULT_STEP_COLOR: StepColorPreset = '#3B82F6'
|
||||
|
||||
const DEFAULT_STEP_COLORS_BY_ID: Record<string, StepColorPreset> = {
|
||||
processing: '#3B82F6',
|
||||
'ready-for-shipping': '#F59E0B',
|
||||
shipped: '#8B5CF6',
|
||||
delivered: '#22C55E',
|
||||
}
|
||||
|
||||
export function isStepColorPreset(value: string): value is StepColorPreset {
|
||||
return STEP_COLOR_PRESETS.includes(value as StepColorPreset)
|
||||
}
|
||||
|
||||
export function normalizeStepColor(value: unknown, fallback = DEFAULT_STEP_COLOR): StepColorPreset {
|
||||
if (typeof value === 'string' && isStepColorPreset(value)) {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function defaultStepColorForId(id: string, index = 0): StepColorPreset {
|
||||
return DEFAULT_STEP_COLORS_BY_ID[id] ?? STEP_COLOR_PRESETS[index % STEP_COLOR_PRESETS.length]
|
||||
}
|
||||
|
||||
export function stepColorLabel(hex: StepColorPreset) {
|
||||
const index = STEP_COLOR_PRESETS.indexOf(hex)
|
||||
return index >= 0 ? `Color ${index + 1}` : 'Color'
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string) {
|
||||
const normalized = hex.replace('#', '')
|
||||
const value =
|
||||
normalized.length === 3
|
||||
? normalized
|
||||
.split('')
|
||||
.map((char) => char + char)
|
||||
.join('')
|
||||
: normalized
|
||||
|
||||
const int = Number.parseInt(value, 16)
|
||||
return {
|
||||
r: (int >> 16) & 255,
|
||||
g: (int >> 8) & 255,
|
||||
b: int & 255,
|
||||
}
|
||||
}
|
||||
|
||||
export function stepBadgeStyle(color: string) {
|
||||
const { r, g, b } = hexToRgb(color)
|
||||
return {
|
||||
color,
|
||||
background: `rgba(${r}, ${g}, ${b}, 0.14)`,
|
||||
border: `1px solid rgba(${r}, ${g}, ${b}, 0.32)`,
|
||||
} as const
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ProductVariationSelection } from '../services/productVariationService'
|
||||
import { createId } from './id'
|
||||
|
||||
export interface StoreItemDraftRow {
|
||||
id: string
|
||||
storeItemId?: string
|
||||
selections: Record<string, string>
|
||||
price: string
|
||||
stock: string
|
||||
}
|
||||
|
||||
export function getVariationOptions(variation: ProductVariationSelection) {
|
||||
if (variation.selectedOptionIds.length > 0) {
|
||||
return variation.options.filter((option) =>
|
||||
variation.selectedOptionIds.includes(option.id),
|
||||
)
|
||||
}
|
||||
return variation.options
|
||||
}
|
||||
|
||||
export function createEmptyStoreItemRow(): StoreItemDraftRow {
|
||||
return {
|
||||
id: createId(),
|
||||
selections: {},
|
||||
price: '',
|
||||
stock: '1',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { StoreItem } from '../services/storeItemService'
|
||||
import { hasStoreItemDiscount } from './irtPrice'
|
||||
|
||||
export interface StoreProductListing {
|
||||
productId: string
|
||||
productTitle: string
|
||||
productNameFa: string
|
||||
productImage: string | null
|
||||
productTotalStock: number
|
||||
variantCount: number
|
||||
variants: StoreItem[]
|
||||
representative: StoreItem
|
||||
displayPrice: number | null
|
||||
displayDiscountedPrice: number | null
|
||||
showFestival: boolean
|
||||
}
|
||||
|
||||
function effectivePrice(item: StoreItem): number | null {
|
||||
if (hasStoreItemDiscount(item.price, item.discountedPrice)) {
|
||||
return item.discountedPrice
|
||||
}
|
||||
return item.price
|
||||
}
|
||||
|
||||
function pickDisplayPrice(variants: StoreItem[]) {
|
||||
let displayPrice: number | null = null
|
||||
let displayDiscountedPrice: number | null = null
|
||||
let minEffective = Infinity
|
||||
|
||||
for (const variant of variants) {
|
||||
const effective = effectivePrice(variant)
|
||||
if (effective === null) continue
|
||||
if (effective < minEffective) {
|
||||
minEffective = effective
|
||||
if (hasStoreItemDiscount(variant.price, variant.discountedPrice)) {
|
||||
displayPrice = variant.price
|
||||
displayDiscountedPrice = variant.discountedPrice
|
||||
} else {
|
||||
displayPrice = variant.price
|
||||
displayDiscountedPrice = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (minEffective === Infinity) {
|
||||
const first = variants[0]
|
||||
return {
|
||||
displayPrice: first?.price ?? null,
|
||||
displayDiscountedPrice: first?.discountedPrice ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
return { displayPrice, displayDiscountedPrice }
|
||||
}
|
||||
|
||||
export function groupStoreItemsByProduct(items: StoreItem[]): StoreProductListing[] {
|
||||
const byProduct = new Map<string, StoreItem[]>()
|
||||
|
||||
for (const item of items) {
|
||||
const variants = byProduct.get(item.productId) ?? []
|
||||
variants.push(item)
|
||||
byProduct.set(item.productId, variants)
|
||||
}
|
||||
|
||||
return [...byProduct.values()]
|
||||
.map((variants) => {
|
||||
const sorted = [...variants].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
)
|
||||
const representative = sorted[0]
|
||||
const { displayPrice, displayDiscountedPrice } = pickDisplayPrice(sorted)
|
||||
|
||||
return {
|
||||
productId: representative.productId,
|
||||
productTitle: representative.productTitle,
|
||||
productNameFa: representative.productNameFa,
|
||||
productImage: representative.productImage,
|
||||
productTotalStock: representative.productTotalStock,
|
||||
variantCount: sorted.length,
|
||||
variants: sorted,
|
||||
representative,
|
||||
displayPrice,
|
||||
displayDiscountedPrice,
|
||||
showFestival: sorted.some(
|
||||
(variant) => variant.isFestival || (variant.rewardPoints ?? 0) > 0,
|
||||
),
|
||||
}
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.representative.createdAt).getTime() -
|
||||
new Date(a.representative.createdAt).getTime(),
|
||||
)
|
||||
}
|
||||
|
||||
export function formatVariantCount(count: number): string {
|
||||
return count === 1 ? '1 variant' : `${count} variants`
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { StoreItem } from '../services/storeItemService'
|
||||
import type { StoreSpecialStoreItem } from '../types/storeSpecial'
|
||||
import {
|
||||
groupStoreItemsByProduct,
|
||||
type StoreProductListing,
|
||||
} from './storeProductGroups'
|
||||
|
||||
export interface SpecialProductListing extends StoreProductListing {
|
||||
storeItemId: string
|
||||
}
|
||||
|
||||
function specialStoreItemToVariants(item: StoreSpecialStoreItem): StoreItem[] {
|
||||
const productTotalStock = item.variants.reduce(
|
||||
(sum, variant) => sum + (variant.stockQuantity ?? 0),
|
||||
0,
|
||||
)
|
||||
|
||||
return item.variants.map((variant) => ({
|
||||
id: variant.id,
|
||||
storeItemId: item.id,
|
||||
productId: item.productId,
|
||||
productTitle: item.productTitle,
|
||||
productNameFa: item.productNameFa,
|
||||
productImage: item.productImage,
|
||||
productTotalStock,
|
||||
selections: variant.selections,
|
||||
label: variant.label,
|
||||
price: variant.price,
|
||||
discountedPrice: variant.discountedPrice,
|
||||
stockQuantity: variant.stockQuantity,
|
||||
rewardPoints: variant.rewardPoints,
|
||||
isFestival: variant.isFestival,
|
||||
sortOrder: variant.sortOrder,
|
||||
createdAt: '',
|
||||
}))
|
||||
}
|
||||
|
||||
export function specialItemsToListings(items: StoreSpecialStoreItem[]): SpecialProductListing[] {
|
||||
const variants = items.flatMap(specialStoreItemToVariants)
|
||||
const listings = groupStoreItemsByProduct(variants)
|
||||
|
||||
return listings.map((listing) => ({
|
||||
...listing,
|
||||
storeItemId: listing.representative.storeItemId ?? listing.representative.id,
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user