Fix store item variant defaults and listing sorting
Auto-select first option in variation modals and sort store items by availability, then update time. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
b861ab3dc1
commit
5f4b0b4f92
@@ -72,6 +72,34 @@ export function CreateStoreItemsModal({
|
|||||||
[variations],
|
[variations],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// When modal loads/variations change, pre-select the first option for every variation.
|
||||||
|
// This matches the expected UX and avoids empty selects on initial open.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted || closing) return
|
||||||
|
if (formVariations.length === 0) return
|
||||||
|
|
||||||
|
setRows((prev) => {
|
||||||
|
// Avoid re-setting state if everything is already selected.
|
||||||
|
let changed = false
|
||||||
|
const next = prev.map((row) => {
|
||||||
|
const nextSelections = { ...row.selections }
|
||||||
|
for (const variation of formVariations) {
|
||||||
|
const current = nextSelections[variation.id]
|
||||||
|
if (current) continue
|
||||||
|
const options = getVariationOptions(variation)
|
||||||
|
const first = options[0]
|
||||||
|
if (first?.id) {
|
||||||
|
nextSelections[variation.id] = first.id
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? { ...row, selections: nextSelections } : row
|
||||||
|
})
|
||||||
|
|
||||||
|
return changed ? next : prev
|
||||||
|
})
|
||||||
|
}, [formVariations, mounted, closing])
|
||||||
|
|
||||||
const gridTemplate = useMemo(() => {
|
const gridTemplate = useMemo(() => {
|
||||||
const variationCols = formVariations.map(() => 'minmax(110px, 1fr)').join(' ')
|
const variationCols = formVariations.map(() => 'minmax(110px, 1fr)').join(' ')
|
||||||
const cols = [variationCols, 'minmax(120px, 1fr)', '90px', '72px', '32px']
|
const cols = [variationCols, 'minmax(120px, 1fr)', '90px', '72px', '32px']
|
||||||
|
|||||||
@@ -83,6 +83,32 @@ export function EditStoreItemsModal({
|
|||||||
[variations],
|
[variations],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// When modal loads, ensure each variation select has a valid default selection.
|
||||||
|
// This fixes cases where existing store-item variants don't return selections for all variations.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted || closing) return
|
||||||
|
if (formVariations.length === 0) return
|
||||||
|
|
||||||
|
setRows((prev) => {
|
||||||
|
let changed = false
|
||||||
|
const next = prev.map((row) => {
|
||||||
|
const nextSelections = { ...row.selections }
|
||||||
|
for (const variation of formVariations) {
|
||||||
|
const current = nextSelections[variation.id]
|
||||||
|
if (current) continue
|
||||||
|
const options = getVariationOptions(variation)
|
||||||
|
const first = options[0]
|
||||||
|
if (first?.id) {
|
||||||
|
nextSelections[variation.id] = first.id
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? { ...row, selections: nextSelections } : row
|
||||||
|
})
|
||||||
|
return changed ? next : prev
|
||||||
|
})
|
||||||
|
}, [formVariations, mounted, closing])
|
||||||
|
|
||||||
const gridTemplate = useMemo(() => {
|
const gridTemplate = useMemo(() => {
|
||||||
const variationCols = formVariations.map(() => 'minmax(110px, 1fr)').join(' ')
|
const variationCols = formVariations.map(() => 'minmax(110px, 1fr)').join(' ')
|
||||||
const cols = [variationCols, 'minmax(120px, 1fr)', '90px', '72px', '32px']
|
const cols = [variationCols, 'minmax(120px, 1fr)', '90px', '72px', '32px']
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export function PickStoreSpecialItemsModal({
|
|||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||||
|
const [selectOpen, setSelectOpen] = useState(false)
|
||||||
|
|
||||||
const existingSet = useMemo(() => new Set(existingStoreItemIds), [existingStoreItemIds])
|
const existingSet = useMemo(() => new Set(existingStoreItemIds), [existingStoreItemIds])
|
||||||
|
|
||||||
@@ -85,15 +86,28 @@ export function PickStoreSpecialItemsModal({
|
|||||||
const items = await listAllStoreItems(signal)
|
const items = await listAllStoreItems(signal)
|
||||||
const listings = groupStoreItemsByProduct(items)
|
const listings = groupStoreItemsByProduct(items)
|
||||||
const nextOptions = listings
|
const nextOptions = listings
|
||||||
.map((listing) => {
|
.map(
|
||||||
|
(listing): StoreItemSearchOption | null => {
|
||||||
const storeItemId = listing.representative.storeItemId ?? listing.representative.id
|
const storeItemId = listing.representative.storeItemId ?? listing.representative.id
|
||||||
|
const firstPricedVariant = listing.variants.find((v) => v.price !== null) ?? null
|
||||||
|
const price = firstPricedVariant?.price ?? null
|
||||||
|
const hasPrice = listing.variants.some((v) => v.price !== null)
|
||||||
|
const hasInStock = listing.variants.some(
|
||||||
|
(v) => v.stockQuantity === null || v.stockQuantity > 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!hasPrice && !hasInStock) return null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: storeItemId,
|
id: storeItemId,
|
||||||
title: listing.productTitle,
|
title: listing.productTitle,
|
||||||
nameFa: listing.productNameFa,
|
nameFa: listing.productNameFa,
|
||||||
image: listing.productImage,
|
image: listing.productImage,
|
||||||
|
price,
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
.filter((option): option is StoreItemSearchOption => option !== null)
|
||||||
.filter((option) => !existingSet.has(option.id))
|
.filter((option) => !existingSet.has(option.id))
|
||||||
|
|
||||||
setOptions(nextOptions)
|
setOptions(nextOptions)
|
||||||
@@ -133,7 +147,11 @@ export function PickStoreSpecialItemsModal({
|
|||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
onClick={(e) => {
|
||||||
|
if (e.target !== e.currentTarget) return
|
||||||
|
if (selectOpen) return
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -175,6 +193,7 @@ export function PickStoreSpecialItemsModal({
|
|||||||
onToggle={toggleSelection}
|
onToggle={toggleSelection}
|
||||||
placeholder={t('website.specialItems.pickSearch')}
|
placeholder={t('website.specialItems.pickSearch')}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
|
onOpenChange={setSelectOpen}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,16 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.optionPrice {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
.selectedList {
|
.selectedList {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import { Search, X } from 'lucide-react'
|
import { Search, X } from 'lucide-react'
|
||||||
import { useLocale } from '@meshkee/dashboard-ui'
|
import { useLocale } from '@meshkee/dashboard-ui'
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
|
import { formatIrtPrice } from '../utils/irtPrice'
|
||||||
import selectStyles from './SearchableSelect.module.css'
|
import selectStyles from './SearchableSelect.module.css'
|
||||||
import styles from './StoreItemMultiSearchSelect.module.css'
|
import styles from './StoreItemMultiSearchSelect.module.css'
|
||||||
|
|
||||||
@@ -10,6 +11,11 @@ export interface StoreItemSearchOption {
|
|||||||
title: string
|
title: string
|
||||||
nameFa: string
|
nameFa: string
|
||||||
image: string | null
|
image: string | null
|
||||||
|
/**
|
||||||
|
* Optional because this shared component is also used for brands/categories.
|
||||||
|
* When provided, we render it as a single price line.
|
||||||
|
*/
|
||||||
|
price?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StoreItemMultiSearchSelectProps {
|
interface StoreItemMultiSearchSelectProps {
|
||||||
@@ -19,6 +25,11 @@ interface StoreItemMultiSearchSelectProps {
|
|||||||
placeholder?: string
|
placeholder?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
minSearchLength?: number
|
minSearchLength?: number
|
||||||
|
/**
|
||||||
|
* Optional hook for parents that need to know when the dropdown is open
|
||||||
|
* (e.g. to prevent modal outside-click closing).
|
||||||
|
*/
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StoreItemMultiSearchSelect({
|
export function StoreItemMultiSearchSelect({
|
||||||
@@ -28,6 +39,7 @@ export function StoreItemMultiSearchSelect({
|
|||||||
placeholder,
|
placeholder,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
minSearchLength = 3,
|
minSearchLength = 3,
|
||||||
|
onOpenChange,
|
||||||
}: StoreItemMultiSearchSelectProps) {
|
}: StoreItemMultiSearchSelectProps) {
|
||||||
const t = useT()
|
const t = useT()
|
||||||
const { locale } = useLocale()
|
const { locale } = useLocale()
|
||||||
@@ -40,6 +52,10 @@ export function StoreItemMultiSearchSelect({
|
|||||||
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
||||||
const searchPlaceholder = placeholder ?? t('storeItems.search.minChars', { count: minSearchLength })
|
const searchPlaceholder = placeholder ?? t('storeItems.search.minChars', { count: minSearchLength })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onOpenChange?.(open)
|
||||||
|
}, [open, onOpenChange])
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const trimmed = query.trim()
|
const trimmed = query.trim()
|
||||||
if (trimmed.length < minSearchLength) return []
|
if (trimmed.length < minSearchLength) return []
|
||||||
@@ -79,6 +95,9 @@ export function StoreItemMultiSearchSelect({
|
|||||||
: ''
|
: ''
|
||||||
: option.nameFa
|
: option.nameFa
|
||||||
|
|
||||||
|
const priceLine =
|
||||||
|
option.price === undefined ? null : option.price === null ? ' - ' : formatIrtPrice(option.price)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={styles.optionMeta}>
|
<span className={styles.optionMeta}>
|
||||||
<span
|
<span
|
||||||
@@ -97,6 +116,7 @@ export function StoreItemMultiSearchSelect({
|
|||||||
{secondary}
|
{secondary}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{priceLine ? <span className={styles.optionPrice}>{priceLine}</span> : null}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,31 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.titleRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyChip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary);
|
||||||
|
background: rgba(var(--primary-rgb) / 0.08);
|
||||||
|
border: 1px solid rgba(var(--primary-rgb) / 0.18);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
user-select: text;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.headerActions {
|
.headerActions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -83,7 +83,12 @@ export function StoreSpecialCarousel({
|
|||||||
return (
|
return (
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<h3 className={styles.title}>{special.title}</h3>
|
<div className={styles.titleRow}>
|
||||||
|
<span className={styles.keyChip} dir="ltr" title="Slug (key) — قابل کپی کردن">
|
||||||
|
{special.key}
|
||||||
|
</span>
|
||||||
|
<h3 className={styles.title}>{special.title}</h3>
|
||||||
|
</div>
|
||||||
<div className={styles.headerActions}>
|
<div className={styles.headerActions}>
|
||||||
<Tooltip label={t('website.specialItems.editCategory')}>
|
<Tooltip label={t('website.specialItems.editCategory')}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
.tip {
|
.tip {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
transform: translate(-50%, calc(-100% + 4px));
|
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -21,7 +20,7 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
|
transition: opacity 0.15s ease, visibility 0.15s;
|
||||||
background: rgba(255, 255, 255, 0.95);
|
background: rgba(255, 255, 255, 0.95);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
-webkit-backdrop-filter: blur(12px);
|
-webkit-backdrop-filter: blur(12px);
|
||||||
@@ -33,15 +32,22 @@
|
|||||||
.tipVisible {
|
.tipVisible {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
transform: translate(-50%, -100%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tip::after {
|
.tip::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
left: var(--tooltip-arrow-left, 50%);
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
border: 5px solid transparent;
|
border: 5px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tipTop::after {
|
||||||
|
top: 100%;
|
||||||
border-top-color: rgba(255, 255, 255, 0.95);
|
border-top-color: rgba(255, 255, 255, 0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tipBottom::after {
|
||||||
|
bottom: 100%;
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.95);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,36 +1,88 @@
|
|||||||
import { useEffect, useRef, useState, type ReactElement, type ReactNode } from 'react'
|
import {
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type CSSProperties,
|
||||||
|
type ReactElement,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import styles from './Tooltip.module.css'
|
import styles from './Tooltip.module.css'
|
||||||
|
|
||||||
|
const GAP = 8
|
||||||
|
const PAD = 8
|
||||||
|
const ARROW = 5
|
||||||
|
|
||||||
interface TooltipProps {
|
interface TooltipProps {
|
||||||
label: ReactNode
|
label: ReactNode
|
||||||
children: ReactElement
|
children: ReactElement
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TipPlacement = 'top' | 'bottom'
|
||||||
|
|
||||||
export function Tooltip({ label, children }: TooltipProps) {
|
export function Tooltip({ label, children }: TooltipProps) {
|
||||||
const wrapRef = useRef<HTMLSpanElement>(null)
|
const wrapRef = useRef<HTMLSpanElement>(null)
|
||||||
|
const tipRef = useRef<HTMLSpanElement>(null)
|
||||||
const [visible, setVisible] = useState(false)
|
const [visible, setVisible] = useState(false)
|
||||||
const [coords, setCoords] = useState({ top: 0, left: 0 })
|
const [ready, setReady] = useState(false)
|
||||||
|
const [coords, setCoords] = useState<{
|
||||||
|
top: number
|
||||||
|
left: number
|
||||||
|
placement: TipPlacement
|
||||||
|
arrowLeft: number
|
||||||
|
}>({ top: 0, left: 0, placement: 'top', arrowLeft: 50 })
|
||||||
|
|
||||||
function updatePosition() {
|
function updatePosition() {
|
||||||
const el = wrapRef.current
|
const trigger = wrapRef.current
|
||||||
if (!el) return
|
const tip = tipRef.current
|
||||||
const rect = el.getBoundingClientRect()
|
if (!trigger || !tip) return
|
||||||
setCoords({
|
|
||||||
top: rect.top - 8,
|
const rect = trigger.getBoundingClientRect()
|
||||||
left: rect.left + rect.width / 2,
|
const vw = window.innerWidth
|
||||||
})
|
const vh = window.innerHeight
|
||||||
|
const tipW = tip.offsetWidth
|
||||||
|
const tipH = tip.offsetHeight
|
||||||
|
|
||||||
|
const spaceAbove = rect.top - PAD
|
||||||
|
const spaceBelow = vh - rect.bottom - PAD
|
||||||
|
const needH = tipH + GAP
|
||||||
|
const fitsAbove = spaceAbove >= needH
|
||||||
|
const fitsBelow = spaceBelow >= needH
|
||||||
|
const placement: TipPlacement =
|
||||||
|
fitsAbove || (!fitsBelow && spaceAbove >= spaceBelow) ? 'top' : 'bottom'
|
||||||
|
|
||||||
|
const top = placement === 'top' ? rect.top - GAP - tipH : rect.bottom + GAP
|
||||||
|
|
||||||
|
let left = rect.left + rect.width / 2 - tipW / 2
|
||||||
|
left = Math.min(Math.max(left, PAD), Math.max(PAD, vw - PAD - tipW))
|
||||||
|
|
||||||
|
const triggerCenter = rect.left + rect.width / 2
|
||||||
|
const arrowLeft = Math.min(Math.max(triggerCenter - left, ARROW + 4), Math.max(ARROW + 4, tipW - ARROW - 4))
|
||||||
|
|
||||||
|
setCoords({ top, left, placement, arrowLeft })
|
||||||
|
setReady(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function show() {
|
function show() {
|
||||||
updatePosition()
|
|
||||||
setVisible(true)
|
setVisible(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function hide() {
|
function hide() {
|
||||||
setVisible(false)
|
setVisible(false)
|
||||||
|
setReady(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!visible) return
|
||||||
|
updatePosition()
|
||||||
|
}, [visible, label])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || ready) return
|
||||||
|
updatePosition()
|
||||||
|
}, [visible, ready, label])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) return
|
if (!visible) return
|
||||||
|
|
||||||
@@ -46,6 +98,12 @@ export function Tooltip({ label, children }: TooltipProps) {
|
|||||||
}
|
}
|
||||||
}, [visible])
|
}, [visible])
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
top: coords.top,
|
||||||
|
left: coords.left,
|
||||||
|
['--tooltip-arrow-left' as string]: `${coords.arrowLeft}px`,
|
||||||
|
} as CSSProperties
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
ref={wrapRef}
|
ref={wrapRef}
|
||||||
@@ -63,9 +121,12 @@ export function Tooltip({ label, children }: TooltipProps) {
|
|||||||
{visible
|
{visible
|
||||||
? createPortal(
|
? createPortal(
|
||||||
<span
|
<span
|
||||||
className={`${styles.tip} ${styles.tipVisible}`}
|
ref={tipRef}
|
||||||
|
className={`${styles.tip} ${ready ? styles.tipVisible : ''} ${
|
||||||
|
coords.placement === 'bottom' ? styles.tipBottom : styles.tipTop
|
||||||
|
}`}
|
||||||
role="tooltip"
|
role="tooltip"
|
||||||
style={{ top: coords.top, left: coords.left }}
|
style={style}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>,
|
</span>,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface StoreItem {
|
|||||||
isFestival: boolean
|
isFestival: boolean
|
||||||
sortOrder: number
|
sortOrder: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoreItemsListResponse {
|
export interface StoreItemsListResponse {
|
||||||
|
|||||||
@@ -53,6 +53,16 @@ function pickDisplayPrice(variants: StoreItem[]) {
|
|||||||
return { displayPrice, displayDiscountedPrice }
|
return { displayPrice, displayDiscountedPrice }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseDateMs(value: string | undefined) {
|
||||||
|
const ms = Date.parse(value ?? '')
|
||||||
|
return Number.isFinite(ms) ? ms : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVariantInStock(variant: StoreItem) {
|
||||||
|
// `stockQuantity = null` means unlimited stock (in stock).
|
||||||
|
return variant.stockQuantity === null || variant.stockQuantity > 0
|
||||||
|
}
|
||||||
|
|
||||||
export function groupStoreItemsByProduct(items: StoreItem[]): StoreProductListing[] {
|
export function groupStoreItemsByProduct(items: StoreItem[]): StoreProductListing[] {
|
||||||
const byProduct = new Map<string, StoreItem[]>()
|
const byProduct = new Map<string, StoreItem[]>()
|
||||||
|
|
||||||
@@ -64,10 +74,17 @@ export function groupStoreItemsByProduct(items: StoreItem[]): StoreProductListin
|
|||||||
|
|
||||||
return [...byProduct.values()]
|
return [...byProduct.values()]
|
||||||
.map((variants) => {
|
.map((variants) => {
|
||||||
const sorted = [...variants].sort(
|
const sorted = [...variants].sort((a, b) => {
|
||||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
const aInStock = isVariantInStock(a)
|
||||||
)
|
const bInStock = isVariantInStock(b)
|
||||||
const representative = sorted[0]
|
if (aInStock !== bInStock) return aInStock ? -1 : 1
|
||||||
|
|
||||||
|
const updatedDiff = parseDateMs(b.updatedAt) - parseDateMs(a.updatedAt)
|
||||||
|
if (updatedDiff !== 0) return updatedDiff
|
||||||
|
return parseDateMs(b.createdAt) - parseDateMs(a.createdAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
const representative = sorted[0]!
|
||||||
const { displayPrice, displayDiscountedPrice } = pickDisplayPrice(sorted)
|
const { displayPrice, displayDiscountedPrice } = pickDisplayPrice(sorted)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -87,9 +104,15 @@ export function groupStoreItemsByProduct(items: StoreItem[]): StoreProductListin
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) => {
|
||||||
new Date(b.representative.createdAt).getTime() -
|
const aInStock = a.variants.some(isVariantInStock)
|
||||||
new Date(a.representative.createdAt).getTime(),
|
const bInStock = b.variants.some(isVariantInStock)
|
||||||
|
if (aInStock !== bInStock) return aInStock ? -1 : 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
parseDateMs(b.representative.updatedAt) - parseDateMs(a.representative.updatedAt)
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ function specialStoreItemToVariants(item: StoreSpecialStoreItem): StoreItem[] {
|
|||||||
isFestival: variant.isFestival,
|
isFestival: variant.isFestival,
|
||||||
sortOrder: variant.sortOrder,
|
sortOrder: variant.sortOrder,
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
|
updatedAt: '',
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
.tip {
|
.tip {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
transform: translate(-50%, calc(-100% + 4px));
|
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -16,7 +15,7 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
|
transition: opacity 0.15s ease, visibility 0.15s;
|
||||||
background: var(--elevated-surface);
|
background: var(--elevated-surface);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
-webkit-backdrop-filter: blur(12px);
|
-webkit-backdrop-filter: blur(12px);
|
||||||
@@ -28,15 +27,22 @@
|
|||||||
.tipVisible {
|
.tipVisible {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
transform: translate(-50%, -100%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tip::after {
|
.tip::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
left: var(--tooltip-arrow-left, 50%);
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
border: 5px solid transparent;
|
border: 5px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tipTop::after {
|
||||||
|
top: 100%;
|
||||||
border-top-color: var(--elevated-surface);
|
border-top-color: var(--elevated-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tipBottom::after {
|
||||||
|
bottom: 100%;
|
||||||
|
border-bottom-color: var(--elevated-surface);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,36 +1,87 @@
|
|||||||
import { useEffect, useRef, useState, type ReactElement } from 'react'
|
import {
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type CSSProperties,
|
||||||
|
type ReactElement,
|
||||||
|
} from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import styles from './Tooltip.module.css'
|
import styles from './Tooltip.module.css'
|
||||||
|
|
||||||
|
const GAP = 8
|
||||||
|
const PAD = 8
|
||||||
|
const ARROW = 5
|
||||||
|
|
||||||
interface TooltipProps {
|
interface TooltipProps {
|
||||||
label: string
|
label: string
|
||||||
children: ReactElement
|
children: ReactElement
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TipPlacement = 'top' | 'bottom'
|
||||||
|
|
||||||
export function Tooltip({ label, children }: TooltipProps) {
|
export function Tooltip({ label, children }: TooltipProps) {
|
||||||
const wrapRef = useRef<HTMLSpanElement>(null)
|
const wrapRef = useRef<HTMLSpanElement>(null)
|
||||||
|
const tipRef = useRef<HTMLSpanElement>(null)
|
||||||
const [visible, setVisible] = useState(false)
|
const [visible, setVisible] = useState(false)
|
||||||
const [coords, setCoords] = useState({ top: 0, left: 0 })
|
const [ready, setReady] = useState(false)
|
||||||
|
const [coords, setCoords] = useState<{
|
||||||
|
top: number
|
||||||
|
left: number
|
||||||
|
placement: TipPlacement
|
||||||
|
arrowLeft: number
|
||||||
|
}>({ top: 0, left: 0, placement: 'top', arrowLeft: 50 })
|
||||||
|
|
||||||
function updatePosition() {
|
function updatePosition() {
|
||||||
const el = wrapRef.current
|
const trigger = wrapRef.current
|
||||||
if (!el) return
|
const tip = tipRef.current
|
||||||
const rect = el.getBoundingClientRect()
|
if (!trigger || !tip) return
|
||||||
setCoords({
|
|
||||||
top: rect.top - 8,
|
const rect = trigger.getBoundingClientRect()
|
||||||
left: rect.left + rect.width / 2,
|
const vw = window.innerWidth
|
||||||
})
|
const vh = window.innerHeight
|
||||||
|
const tipW = tip.offsetWidth
|
||||||
|
const tipH = tip.offsetHeight
|
||||||
|
|
||||||
|
const spaceAbove = rect.top - PAD
|
||||||
|
const spaceBelow = vh - rect.bottom - PAD
|
||||||
|
const needH = tipH + GAP
|
||||||
|
const fitsAbove = spaceAbove >= needH
|
||||||
|
const fitsBelow = spaceBelow >= needH
|
||||||
|
const placement: TipPlacement =
|
||||||
|
fitsAbove || (!fitsBelow && spaceAbove >= spaceBelow) ? 'top' : 'bottom'
|
||||||
|
|
||||||
|
const top = placement === 'top' ? rect.top - GAP - tipH : rect.bottom + GAP
|
||||||
|
|
||||||
|
let left = rect.left + rect.width / 2 - tipW / 2
|
||||||
|
left = Math.min(Math.max(left, PAD), Math.max(PAD, vw - PAD - tipW))
|
||||||
|
|
||||||
|
const triggerCenter = rect.left + rect.width / 2
|
||||||
|
const arrowLeft = Math.min(Math.max(triggerCenter - left, ARROW + 4), Math.max(ARROW + 4, tipW - ARROW - 4))
|
||||||
|
|
||||||
|
setCoords({ top, left, placement, arrowLeft })
|
||||||
|
setReady(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function show() {
|
function show() {
|
||||||
updatePosition()
|
|
||||||
setVisible(true)
|
setVisible(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function hide() {
|
function hide() {
|
||||||
setVisible(false)
|
setVisible(false)
|
||||||
|
setReady(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!visible) return
|
||||||
|
updatePosition()
|
||||||
|
}, [visible, label])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || ready) return
|
||||||
|
updatePosition()
|
||||||
|
}, [visible, ready, label])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) return
|
if (!visible) return
|
||||||
|
|
||||||
@@ -46,6 +97,12 @@ export function Tooltip({ label, children }: TooltipProps) {
|
|||||||
}
|
}
|
||||||
}, [visible])
|
}, [visible])
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
top: coords.top,
|
||||||
|
left: coords.left,
|
||||||
|
['--tooltip-arrow-left' as string]: `${coords.arrowLeft}px`,
|
||||||
|
} as CSSProperties
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
ref={wrapRef}
|
ref={wrapRef}
|
||||||
@@ -63,9 +120,12 @@ export function Tooltip({ label, children }: TooltipProps) {
|
|||||||
{visible
|
{visible
|
||||||
? createPortal(
|
? createPortal(
|
||||||
<span
|
<span
|
||||||
className={`${styles.tip} ${styles.tipVisible}`}
|
ref={tipRef}
|
||||||
|
className={`${styles.tip} ${ready ? styles.tipVisible : ''} ${
|
||||||
|
coords.placement === 'bottom' ? styles.tipBottom : styles.tipTop
|
||||||
|
}`}
|
||||||
role="tooltip"
|
role="tooltip"
|
||||||
style={{ top: coords.top, left: coords.left }}
|
style={style}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>,
|
</span>,
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
.menu {
|
.menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 1200;
|
z-index: 1200;
|
||||||
transform: translateX(-100%);
|
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -98,10 +97,10 @@
|
|||||||
@keyframes menuIn {
|
@keyframes menuIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(-100%) translateY(-4px);
|
transform: translateY(-4px);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateX(-100%) translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { Eraser, MoreHorizontal, Trash2, Users } from 'lucide-react'
|
import { Eraser, MoreHorizontal, Trash2, Users } from 'lucide-react'
|
||||||
|
import { fitFixedPopover } from '../utils/fitFixedPopover'
|
||||||
import styles from './BusinessActionsOverflowMenu.module.css'
|
import styles from './BusinessActionsOverflowMenu.module.css'
|
||||||
|
|
||||||
type PopoverPosition = {
|
type PopoverPosition = {
|
||||||
@@ -31,10 +32,11 @@ export function BusinessActionsOverflowMenu({
|
|||||||
const rect = triggerRef.current?.getBoundingClientRect()
|
const rect = triggerRef.current?.getBoundingClientRect()
|
||||||
if (!rect) return
|
if (!rect) return
|
||||||
|
|
||||||
setPopoverPos({
|
const size = popoverRef.current
|
||||||
top: rect.bottom + 8,
|
? { width: popoverRef.current.offsetWidth, height: popoverRef.current.offsetHeight }
|
||||||
left: rect.right,
|
: { width: 220, height: 160 }
|
||||||
})
|
|
||||||
|
setPopoverPos(fitFixedPopover(rect, size, 'end'))
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleOpen() {
|
function toggleOpen() {
|
||||||
@@ -47,6 +49,11 @@ export function BusinessActionsOverflowMenu({
|
|||||||
setOpen(true)
|
setOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
updatePopoverPosition()
|
||||||
|
}, [open])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
UserRound,
|
UserRound,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { fitFixedPopover } from '../utils/fitFixedPopover'
|
||||||
import styles from './BusinessActionsOverflowMenu.module.css'
|
import styles from './BusinessActionsOverflowMenu.module.css'
|
||||||
|
|
||||||
type PopoverPosition = {
|
type PopoverPosition = {
|
||||||
@@ -52,10 +53,11 @@ export function DomainLinksOverflowMenu({ host, onRemove }: DomainLinksOverflowM
|
|||||||
const rect = triggerRef.current?.getBoundingClientRect()
|
const rect = triggerRef.current?.getBoundingClientRect()
|
||||||
if (!rect) return
|
if (!rect) return
|
||||||
|
|
||||||
setPopoverPos({
|
const size = popoverRef.current
|
||||||
top: rect.bottom + 8,
|
? { width: popoverRef.current.offsetWidth, height: popoverRef.current.offsetHeight }
|
||||||
left: rect.right,
|
: { width: 220, height: 220 }
|
||||||
})
|
|
||||||
|
setPopoverPos(fitFixedPopover(rect, size, 'end'))
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleOpen() {
|
function toggleOpen() {
|
||||||
@@ -68,6 +70,11 @@ export function DomainLinksOverflowMenu({ host, onRemove }: DomainLinksOverflowM
|
|||||||
setOpen(true)
|
setOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
updatePopoverPosition()
|
||||||
|
}, [open])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,6 @@
|
|||||||
.popover {
|
.popover {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 1200;
|
z-index: 1200;
|
||||||
transform: translateX(-50%);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { Moon, Sun } from 'lucide-react'
|
import { Moon, Sun } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type BusinessPrimaryColorId,
|
type BusinessPrimaryColorId,
|
||||||
} from '../utils/businessPrimaryColors'
|
} from '../utils/businessPrimaryColors'
|
||||||
import type { DashboardThemeMode } from '../services/businessSettingsService'
|
import type { DashboardThemeMode } from '../services/businessSettingsService'
|
||||||
|
import { fitFixedPopover } from '../utils/fitFixedPopover'
|
||||||
import styles from './PrimaryColorSwatchControl.module.css'
|
import styles from './PrimaryColorSwatchControl.module.css'
|
||||||
|
|
||||||
interface PrimaryColorSwatchControlProps {
|
interface PrimaryColorSwatchControlProps {
|
||||||
@@ -41,10 +42,11 @@ export function PrimaryColorSwatchControl({
|
|||||||
const rect = triggerRef.current?.getBoundingClientRect()
|
const rect = triggerRef.current?.getBoundingClientRect()
|
||||||
if (!rect) return
|
if (!rect) return
|
||||||
|
|
||||||
setPopoverPos({
|
const size = popoverRef.current
|
||||||
top: rect.bottom + 8,
|
? { width: popoverRef.current.offsetWidth, height: popoverRef.current.offsetHeight }
|
||||||
left: rect.left + rect.width / 2,
|
: { width: 220, height: 160 }
|
||||||
})
|
|
||||||
|
setPopoverPos(fitFixedPopover(rect, size, 'center'))
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleOpen() {
|
function toggleOpen() {
|
||||||
@@ -72,6 +74,11 @@ export function PrimaryColorSwatchControl({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
updatePopoverPosition()
|
||||||
|
}, [open])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user