Add customer SMS/Excel tools, selection bar, and sticky import toasts.
Bulk and single SMS from Users, row selection with overflow summary, persistent large import toasts, and business SMS sender in Super Admin. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
2a32c46b2e
commit
4c3bf8324e
@@ -0,0 +1,35 @@
|
||||
const GAP = 8
|
||||
const VIEWPORT_PAD = 8
|
||||
|
||||
export type FixedPopoverCoords = {
|
||||
top: number
|
||||
left: number
|
||||
}
|
||||
|
||||
export function fitFixedPopover(
|
||||
trigger: DOMRect,
|
||||
size: { width: number; height: number },
|
||||
align: 'end' | 'center' = 'end',
|
||||
): FixedPopoverCoords {
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
const width = Math.max(1, size.width)
|
||||
const height = Math.max(1, size.height)
|
||||
|
||||
const spaceBelow = vh - trigger.bottom - VIEWPORT_PAD
|
||||
const spaceAbove = trigger.top - VIEWPORT_PAD
|
||||
const placeBelow = spaceBelow >= height + GAP || spaceBelow >= spaceAbove
|
||||
|
||||
let top = placeBelow ? trigger.bottom + GAP : trigger.top - GAP - height
|
||||
top = Math.min(Math.max(top, VIEWPORT_PAD), Math.max(VIEWPORT_PAD, vh - VIEWPORT_PAD - height))
|
||||
|
||||
let left =
|
||||
align === 'center'
|
||||
? trigger.left + trigger.width / 2 - width / 2
|
||||
: trigger.right - width
|
||||
|
||||
const maxLeft = Math.max(VIEWPORT_PAD, vw - VIEWPORT_PAD - width)
|
||||
left = Math.min(Math.max(left, VIEWPORT_PAD), maxLeft)
|
||||
|
||||
return { top, left }
|
||||
}
|
||||
@@ -2,6 +2,14 @@ import { getBusinessDomain } from '../lib/config'
|
||||
|
||||
export type WebsiteLinkKind = 'product' | 'storeItem' | 'blog' | 'portfolio' | 'video' | 'workshop'
|
||||
|
||||
export type WebsiteContentKind =
|
||||
| 'product'
|
||||
| 'blog'
|
||||
| 'portfolio'
|
||||
| 'video'
|
||||
| 'instruction'
|
||||
| 'workshop'
|
||||
|
||||
export type WebsiteLinkFilter = 'category' | 'tag'
|
||||
|
||||
export const WEBSITE_LINK_KINDS: WebsiteLinkKind[] = [
|
||||
@@ -22,12 +30,84 @@ export const WEBSITE_LINK_KIND_PATH: Record<WebsiteLinkKind, string> = {
|
||||
workshop: '/workshops',
|
||||
}
|
||||
|
||||
/** Matches backend `slugifyForUrl` used by website sitemaps / SEO paths. */
|
||||
export function slugifyForWebsitePath(value: string, fallback = 'item'): string {
|
||||
const slug = value
|
||||
.trim()
|
||||
.normalize('NFC')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(
|
||||
/[^\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFFa-zA-Z0-9-]+/g,
|
||||
'',
|
||||
)
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
|
||||
return slug || fallback
|
||||
}
|
||||
|
||||
export function getWebsiteOrigin(domain = getBusinessDomain()): string {
|
||||
const host = domain.trim().replace(/^https?:\/\//i, '').replace(/\/$/, '')
|
||||
const protocol = window.location.protocol === 'http:' ? 'http:' : 'https:'
|
||||
return `${protocol}//${host}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Public storefront detail URL for a content item (canonical Meshkee website paths).
|
||||
* Returns '' when a required slug/title is missing.
|
||||
*/
|
||||
export function buildWebsiteContentUrl(
|
||||
kind: WebsiteContentKind,
|
||||
item: {
|
||||
id: string
|
||||
slug?: string | null
|
||||
title?: string | null
|
||||
titleFa?: string | null
|
||||
nameFa?: string | null
|
||||
nameEn?: string | null
|
||||
},
|
||||
domain = getBusinessDomain(),
|
||||
): string {
|
||||
const origin = getWebsiteOrigin(domain)
|
||||
const id = item.id?.trim()
|
||||
if (!id) return ''
|
||||
|
||||
switch (kind) {
|
||||
case 'product': {
|
||||
const pathSlug = slugifyForWebsitePath(
|
||||
item.nameFa?.trim() || item.nameEn?.trim() || item.title?.trim() || '',
|
||||
'product',
|
||||
)
|
||||
return `${origin}/products/${encodeURIComponent(id)}/${encodeURIComponent(pathSlug)}`
|
||||
}
|
||||
case 'blog': {
|
||||
const pathSlug = slugifyForWebsitePath(item.title?.trim() || '', 'blog')
|
||||
return `${origin}/blog/${encodeURIComponent(id)}/${encodeURIComponent(pathSlug)}`
|
||||
}
|
||||
case 'portfolio': {
|
||||
const pathSlug = slugifyForWebsitePath(
|
||||
item.titleFa?.trim() || item.title?.trim() || '',
|
||||
'portfolio',
|
||||
)
|
||||
return `${origin}/portfolios/${encodeURIComponent(id)}/${encodeURIComponent(pathSlug)}`
|
||||
}
|
||||
case 'video': {
|
||||
const slug = item.slug?.trim()
|
||||
return slug ? `${origin}/videos/${encodeURIComponent(slug)}` : ''
|
||||
}
|
||||
case 'instruction': {
|
||||
const slug = item.slug?.trim()
|
||||
return slug ? `${origin}/instructions/${encodeURIComponent(slug)}` : ''
|
||||
}
|
||||
case 'workshop': {
|
||||
const slug = item.slug?.trim()
|
||||
return slug ? `${origin}/workshops/${encodeURIComponent(slug)}` : ''
|
||||
}
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWebsiteCategoryUrl(
|
||||
kind: WebsiteLinkKind,
|
||||
category: { id: string; slug?: string | null },
|
||||
|
||||
Reference in New Issue
Block a user