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],
|
||||
)
|
||||
|
||||
// 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 variationCols = formVariations.map(() => 'minmax(110px, 1fr)').join(' ')
|
||||
const cols = [variationCols, 'minmax(120px, 1fr)', '90px', '72px', '32px']
|
||||
|
||||
@@ -83,6 +83,32 @@ export function EditStoreItemsModal({
|
||||
[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 variationCols = formVariations.map(() => 'minmax(110px, 1fr)').join(' ')
|
||||
const cols = [variationCols, 'minmax(120px, 1fr)', '90px', '72px', '32px']
|
||||
|
||||
@@ -41,6 +41,7 @@ export function PickStoreSpecialItemsModal({
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||
const [selectOpen, setSelectOpen] = useState(false)
|
||||
|
||||
const existingSet = useMemo(() => new Set(existingStoreItemIds), [existingStoreItemIds])
|
||||
|
||||
@@ -85,15 +86,28 @@ export function PickStoreSpecialItemsModal({
|
||||
const items = await listAllStoreItems(signal)
|
||||
const listings = groupStoreItemsByProduct(items)
|
||||
const nextOptions = listings
|
||||
.map((listing) => {
|
||||
.map(
|
||||
(listing): StoreItemSearchOption | null => {
|
||||
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 {
|
||||
id: storeItemId,
|
||||
title: listing.productTitle,
|
||||
nameFa: listing.productNameFa,
|
||||
image: listing.productImage,
|
||||
price,
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
.filter((option): option is StoreItemSearchOption => option !== null)
|
||||
.filter((option) => !existingSet.has(option.id))
|
||||
|
||||
setOptions(nextOptions)
|
||||
@@ -133,7 +147,11 @@ export function PickStoreSpecialItemsModal({
|
||||
return createPortal(
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<div
|
||||
@@ -175,6 +193,7 @@ export function PickStoreSpecialItemsModal({
|
||||
onToggle={toggleSelection}
|
||||
placeholder={t('website.specialItems.pickSearch')}
|
||||
disabled={isSubmitting}
|
||||
onOpenChange={setSelectOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -107,6 +107,16 @@
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { formatIrtPrice } from '../utils/irtPrice'
|
||||
import selectStyles from './SearchableSelect.module.css'
|
||||
import styles from './StoreItemMultiSearchSelect.module.css'
|
||||
|
||||
@@ -10,6 +11,11 @@ export interface StoreItemSearchOption {
|
||||
title: string
|
||||
nameFa: string
|
||||
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 {
|
||||
@@ -19,6 +25,11 @@ interface StoreItemMultiSearchSelectProps {
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
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({
|
||||
@@ -28,6 +39,7 @@ export function StoreItemMultiSearchSelect({
|
||||
placeholder,
|
||||
disabled = false,
|
||||
minSearchLength = 3,
|
||||
onOpenChange,
|
||||
}: StoreItemMultiSearchSelectProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
@@ -40,6 +52,10 @@ export function StoreItemMultiSearchSelect({
|
||||
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
||||
const searchPlaceholder = placeholder ?? t('storeItems.search.minChars', { count: minSearchLength })
|
||||
|
||||
useEffect(() => {
|
||||
onOpenChange?.(open)
|
||||
}, [open, onOpenChange])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const trimmed = query.trim()
|
||||
if (trimmed.length < minSearchLength) return []
|
||||
@@ -79,6 +95,9 @@ export function StoreItemMultiSearchSelect({
|
||||
: ''
|
||||
: option.nameFa
|
||||
|
||||
const priceLine =
|
||||
option.price === undefined ? null : option.price === null ? ' - ' : formatIrtPrice(option.price)
|
||||
|
||||
return (
|
||||
<span className={styles.optionMeta}>
|
||||
<span
|
||||
@@ -97,6 +116,7 @@ export function StoreItemMultiSearchSelect({
|
||||
{secondary}
|
||||
</span>
|
||||
) : null}
|
||||
{priceLine ? <span className={styles.optionPrice}>{priceLine}</span> : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,31 @@
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -83,7 +83,12 @@ export function StoreSpecialCarousel({
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
<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}>
|
||||
<Tooltip label={t('website.specialItems.editCategory')}>
|
||||
<button
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
.tip {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
transform: translate(-50%, calc(-100% + 4px));
|
||||
padding: 6px 10px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 12px;
|
||||
@@ -21,7 +20,7 @@
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
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);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
@@ -33,15 +32,22 @@
|
||||
.tipVisible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translate(-50%, -100%);
|
||||
}
|
||||
|
||||
.tip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
left: var(--tooltip-arrow-left, 50%);
|
||||
transform: translateX(-50%);
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
.tipTop::after {
|
||||
top: 100%;
|
||||
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 styles from './Tooltip.module.css'
|
||||
|
||||
const GAP = 8
|
||||
const PAD = 8
|
||||
const ARROW = 5
|
||||
|
||||
interface TooltipProps {
|
||||
label: ReactNode
|
||||
children: ReactElement
|
||||
}
|
||||
|
||||
type TipPlacement = 'top' | 'bottom'
|
||||
|
||||
export function Tooltip({ label, children }: TooltipProps) {
|
||||
const wrapRef = useRef<HTMLSpanElement>(null)
|
||||
const tipRef = useRef<HTMLSpanElement>(null)
|
||||
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() {
|
||||
const el = wrapRef.current
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
setCoords({
|
||||
top: rect.top - 8,
|
||||
left: rect.left + rect.width / 2,
|
||||
})
|
||||
const trigger = wrapRef.current
|
||||
const tip = tipRef.current
|
||||
if (!trigger || !tip) return
|
||||
|
||||
const rect = trigger.getBoundingClientRect()
|
||||
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() {
|
||||
updatePosition()
|
||||
setVisible(true)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
setVisible(false)
|
||||
setReady(false)
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!visible) return
|
||||
updatePosition()
|
||||
}, [visible, label])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || ready) return
|
||||
updatePosition()
|
||||
}, [visible, ready, label])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
|
||||
@@ -46,6 +98,12 @@ export function Tooltip({ label, children }: TooltipProps) {
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
const style = {
|
||||
top: coords.top,
|
||||
left: coords.left,
|
||||
['--tooltip-arrow-left' as string]: `${coords.arrowLeft}px`,
|
||||
} as CSSProperties
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={wrapRef}
|
||||
@@ -63,9 +121,12 @@ export function Tooltip({ label, children }: TooltipProps) {
|
||||
{visible
|
||||
? createPortal(
|
||||
<span
|
||||
className={`${styles.tip} ${styles.tipVisible}`}
|
||||
ref={tipRef}
|
||||
className={`${styles.tip} ${ready ? styles.tipVisible : ''} ${
|
||||
coords.placement === 'bottom' ? styles.tipBottom : styles.tipTop
|
||||
}`}
|
||||
role="tooltip"
|
||||
style={{ top: coords.top, left: coords.left }}
|
||||
style={style}
|
||||
>
|
||||
{label}
|
||||
</span>,
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface StoreItem {
|
||||
isFestival: boolean
|
||||
sortOrder: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface StoreItemsListResponse {
|
||||
|
||||
@@ -53,6 +53,16 @@ function pickDisplayPrice(variants: StoreItem[]) {
|
||||
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[] {
|
||||
const byProduct = new Map<string, StoreItem[]>()
|
||||
|
||||
@@ -64,10 +74,17 @@ export function groupStoreItemsByProduct(items: StoreItem[]): StoreProductListin
|
||||
|
||||
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 sorted = [...variants].sort((a, b) => {
|
||||
const aInStock = isVariantInStock(a)
|
||||
const bInStock = isVariantInStock(b)
|
||||
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)
|
||||
|
||||
return {
|
||||
@@ -87,9 +104,15 @@ export function groupStoreItemsByProduct(items: StoreItem[]): StoreProductListin
|
||||
}
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.representative.createdAt).getTime() -
|
||||
new Date(a.representative.createdAt).getTime(),
|
||||
(a, b) => {
|
||||
const aInStock = a.variants.some(isVariantInStock)
|
||||
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,
|
||||
sortOrder: variant.sortOrder,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
.tip {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
transform: translate(-50%, calc(-100% + 4px));
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
@@ -16,7 +15,7 @@
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
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);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
@@ -28,15 +27,22 @@
|
||||
.tipVisible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translate(-50%, -100%);
|
||||
}
|
||||
|
||||
.tip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
left: var(--tooltip-arrow-left, 50%);
|
||||
transform: translateX(-50%);
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
.tipTop::after {
|
||||
top: 100%;
|
||||
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 styles from './Tooltip.module.css'
|
||||
|
||||
const GAP = 8
|
||||
const PAD = 8
|
||||
const ARROW = 5
|
||||
|
||||
interface TooltipProps {
|
||||
label: string
|
||||
children: ReactElement
|
||||
}
|
||||
|
||||
type TipPlacement = 'top' | 'bottom'
|
||||
|
||||
export function Tooltip({ label, children }: TooltipProps) {
|
||||
const wrapRef = useRef<HTMLSpanElement>(null)
|
||||
const tipRef = useRef<HTMLSpanElement>(null)
|
||||
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() {
|
||||
const el = wrapRef.current
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
setCoords({
|
||||
top: rect.top - 8,
|
||||
left: rect.left + rect.width / 2,
|
||||
})
|
||||
const trigger = wrapRef.current
|
||||
const tip = tipRef.current
|
||||
if (!trigger || !tip) return
|
||||
|
||||
const rect = trigger.getBoundingClientRect()
|
||||
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() {
|
||||
updatePosition()
|
||||
setVisible(true)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
setVisible(false)
|
||||
setReady(false)
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!visible) return
|
||||
updatePosition()
|
||||
}, [visible, label])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || ready) return
|
||||
updatePosition()
|
||||
}, [visible, ready, label])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
|
||||
@@ -46,6 +97,12 @@ export function Tooltip({ label, children }: TooltipProps) {
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
const style = {
|
||||
top: coords.top,
|
||||
left: coords.left,
|
||||
['--tooltip-arrow-left' as string]: `${coords.arrowLeft}px`,
|
||||
} as CSSProperties
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={wrapRef}
|
||||
@@ -63,9 +120,12 @@ export function Tooltip({ label, children }: TooltipProps) {
|
||||
{visible
|
||||
? createPortal(
|
||||
<span
|
||||
className={`${styles.tip} ${styles.tipVisible}`}
|
||||
ref={tipRef}
|
||||
className={`${styles.tip} ${ready ? styles.tipVisible : ''} ${
|
||||
coords.placement === 'bottom' ? styles.tipBottom : styles.tipTop
|
||||
}`}
|
||||
role="tooltip"
|
||||
style={{ top: coords.top, left: coords.left }}
|
||||
style={style}
|
||||
>
|
||||
{label}
|
||||
</span>,
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: 1200;
|
||||
transform: translateX(-100%);
|
||||
min-width: 220px;
|
||||
padding: 6px;
|
||||
border-radius: 10px;
|
||||
@@ -98,10 +97,10 @@
|
||||
@keyframes menuIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-100%) translateY(-4px);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
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 { Eraser, MoreHorizontal, Trash2, Users } from 'lucide-react'
|
||||
import { fitFixedPopover } from '../utils/fitFixedPopover'
|
||||
import styles from './BusinessActionsOverflowMenu.module.css'
|
||||
|
||||
type PopoverPosition = {
|
||||
@@ -31,10 +32,11 @@ export function BusinessActionsOverflowMenu({
|
||||
const rect = triggerRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
|
||||
setPopoverPos({
|
||||
top: rect.bottom + 8,
|
||||
left: rect.right,
|
||||
})
|
||||
const size = popoverRef.current
|
||||
? { width: popoverRef.current.offsetWidth, height: popoverRef.current.offsetHeight }
|
||||
: { width: 220, height: 160 }
|
||||
|
||||
setPopoverPos(fitFixedPopover(rect, size, 'end'))
|
||||
}
|
||||
|
||||
function toggleOpen() {
|
||||
@@ -47,6 +49,11 @@ export function BusinessActionsOverflowMenu({
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return
|
||||
updatePopoverPosition()
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
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 {
|
||||
Building2,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Trash2,
|
||||
UserRound,
|
||||
} from 'lucide-react'
|
||||
import { fitFixedPopover } from '../utils/fitFixedPopover'
|
||||
import styles from './BusinessActionsOverflowMenu.module.css'
|
||||
|
||||
type PopoverPosition = {
|
||||
@@ -52,10 +53,11 @@ export function DomainLinksOverflowMenu({ host, onRemove }: DomainLinksOverflowM
|
||||
const rect = triggerRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
|
||||
setPopoverPos({
|
||||
top: rect.bottom + 8,
|
||||
left: rect.right,
|
||||
})
|
||||
const size = popoverRef.current
|
||||
? { width: popoverRef.current.offsetWidth, height: popoverRef.current.offsetHeight }
|
||||
: { width: 220, height: 220 }
|
||||
|
||||
setPopoverPos(fitFixedPopover(rect, size, 'end'))
|
||||
}
|
||||
|
||||
function toggleOpen() {
|
||||
@@ -68,6 +70,11 @@ export function DomainLinksOverflowMenu({ host, onRemove }: DomainLinksOverflowM
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return
|
||||
updatePopoverPosition()
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
.popover {
|
||||
position: fixed;
|
||||
z-index: 1200;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 { Moon, Sun } from 'lucide-react'
|
||||
import {
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type BusinessPrimaryColorId,
|
||||
} from '../utils/businessPrimaryColors'
|
||||
import type { DashboardThemeMode } from '../services/businessSettingsService'
|
||||
import { fitFixedPopover } from '../utils/fitFixedPopover'
|
||||
import styles from './PrimaryColorSwatchControl.module.css'
|
||||
|
||||
interface PrimaryColorSwatchControlProps {
|
||||
@@ -41,10 +42,11 @@ export function PrimaryColorSwatchControl({
|
||||
const rect = triggerRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
|
||||
setPopoverPos({
|
||||
top: rect.bottom + 8,
|
||||
left: rect.left + rect.width / 2,
|
||||
})
|
||||
const size = popoverRef.current
|
||||
? { width: popoverRef.current.offsetWidth, height: popoverRef.current.offsetHeight }
|
||||
: { width: 220, height: 160 }
|
||||
|
||||
setPopoverPos(fitFixedPopover(rect, size, 'center'))
|
||||
}
|
||||
|
||||
function toggleOpen() {
|
||||
@@ -72,6 +74,11 @@ export function PrimaryColorSwatchControl({
|
||||
}
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return
|
||||
updatePopoverPosition()
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
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