Templates library plus per-business prompt list with edit, copy public link, and remove. Co-authored-by: Cursor <cursoragent@cursor.com>
256 lines
6.8 KiB
TypeScript
256 lines
6.8 KiB
TypeScript
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import {
|
|
Building2,
|
|
Eraser,
|
|
ExternalLink,
|
|
FileText,
|
|
Globe,
|
|
MoreHorizontal,
|
|
Trash2,
|
|
UserRound,
|
|
Users,
|
|
} from 'lucide-react'
|
|
import { fitFixedPopover } from '../utils/fitFixedPopover'
|
|
import styles from './BusinessActionsOverflowMenu.module.css'
|
|
|
|
type PopoverPosition = {
|
|
top: number
|
|
left: number
|
|
}
|
|
|
|
interface BusinessActionsOverflowMenuProps {
|
|
businessName: string
|
|
/** Primary apex domain host when set — enables website / dashboard links. */
|
|
host?: string | null
|
|
onAiPrompts: () => void
|
|
onAllUsers: () => void
|
|
onWipeData: () => void
|
|
onRemove: () => void
|
|
}
|
|
|
|
function apexHost(host: string) {
|
|
return host.trim().replace(/^www\./i, '')
|
|
}
|
|
|
|
function siteProtocol(host: string) {
|
|
return /\.local$/i.test(host) ? 'http:' : 'https:'
|
|
}
|
|
|
|
function siteUrl(host: string) {
|
|
return `${siteProtocol(host)}//${host}`
|
|
}
|
|
|
|
function openHost(host: string) {
|
|
window.open(siteUrl(host), '_blank', 'noopener,noreferrer')
|
|
}
|
|
|
|
export function BusinessActionsOverflowMenu({
|
|
businessName,
|
|
host,
|
|
onAiPrompts,
|
|
onAllUsers,
|
|
onWipeData,
|
|
onRemove,
|
|
}: BusinessActionsOverflowMenuProps) {
|
|
const [open, setOpen] = useState(false)
|
|
const [popoverPos, setPopoverPos] = useState<PopoverPosition | null>(null)
|
|
const wrapRef = useRef<HTMLDivElement>(null)
|
|
const popoverRef = useRef<HTMLDivElement>(null)
|
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
|
|
|
const apex = host?.trim() ? apexHost(host) : null
|
|
const linkItems = apex
|
|
? ([
|
|
{
|
|
key: 'website',
|
|
label: 'Open website',
|
|
detail: apex,
|
|
icon: Globe,
|
|
onClick: () => openHost(apex),
|
|
},
|
|
{
|
|
key: 'business',
|
|
label: 'Business dashboard',
|
|
detail: `business.${apex}`,
|
|
icon: Building2,
|
|
onClick: () => openHost(`business.${apex}`),
|
|
},
|
|
{
|
|
key: 'customer',
|
|
label: 'Customer dashboard',
|
|
detail: `customer.${apex}`,
|
|
icon: UserRound,
|
|
onClick: () => openHost(`customer.${apex}`),
|
|
},
|
|
] as const)
|
|
: []
|
|
|
|
function updatePopoverPosition() {
|
|
const rect = triggerRef.current?.getBoundingClientRect()
|
|
if (!rect) return
|
|
|
|
const size = popoverRef.current
|
|
? { width: popoverRef.current.offsetWidth, height: popoverRef.current.offsetHeight }
|
|
: { width: 220, height: linkItems.length ? 280 : 160 }
|
|
|
|
setPopoverPos(fitFixedPopover(rect, size, 'end'))
|
|
}
|
|
|
|
function toggleOpen() {
|
|
if (open) {
|
|
setOpen(false)
|
|
return
|
|
}
|
|
|
|
updatePopoverPosition()
|
|
setOpen(true)
|
|
}
|
|
|
|
useLayoutEffect(() => {
|
|
if (!open) return
|
|
updatePopoverPosition()
|
|
}, [open])
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
|
|
function handleClickOutside(e: MouseEvent) {
|
|
const target = e.target as Node
|
|
if (wrapRef.current?.contains(target)) return
|
|
if (popoverRef.current?.contains(target)) return
|
|
setOpen(false)
|
|
}
|
|
|
|
function handleEscape(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') setOpen(false)
|
|
}
|
|
|
|
function handleReposition() {
|
|
updatePopoverPosition()
|
|
}
|
|
|
|
document.addEventListener('mousedown', handleClickOutside)
|
|
document.addEventListener('keydown', handleEscape)
|
|
window.addEventListener('resize', handleReposition)
|
|
window.addEventListener('scroll', handleReposition, true)
|
|
|
|
return () => {
|
|
document.removeEventListener('mousedown', handleClickOutside)
|
|
document.removeEventListener('keydown', handleEscape)
|
|
window.removeEventListener('resize', handleReposition)
|
|
window.removeEventListener('scroll', handleReposition, true)
|
|
}
|
|
}, [open])
|
|
|
|
return (
|
|
<div className={styles.wrap} ref={wrapRef}>
|
|
<button
|
|
ref={triggerRef}
|
|
type="button"
|
|
className={`${styles.trigger} ${open ? styles.triggerOpen : ''}`}
|
|
onClick={(e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
toggleOpen()
|
|
}}
|
|
title="More actions"
|
|
aria-label={`More actions for ${businessName}`}
|
|
aria-haspopup="menu"
|
|
aria-expanded={open}
|
|
>
|
|
<MoreHorizontal size={16} />
|
|
</button>
|
|
|
|
{open &&
|
|
popoverPos &&
|
|
createPortal(
|
|
<div
|
|
ref={popoverRef}
|
|
className={styles.menu}
|
|
role="menu"
|
|
aria-label={`More actions for ${businessName}`}
|
|
style={{
|
|
top: popoverPos.top,
|
|
left: popoverPos.left,
|
|
}}
|
|
>
|
|
{linkItems.map(({ key, label, detail, icon: Icon, onClick }) => (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
className={styles.item}
|
|
role="menuitem"
|
|
onClick={() => {
|
|
setOpen(false)
|
|
onClick()
|
|
}}
|
|
>
|
|
<Icon size={16} />
|
|
<span className={styles.itemText}>
|
|
<span>{label}</span>
|
|
<span className={styles.itemDetail} dir="ltr">
|
|
{detail}
|
|
<ExternalLink size={12} aria-hidden="true" />
|
|
</span>
|
|
</span>
|
|
</button>
|
|
))}
|
|
{linkItems.length > 0 && <div className={styles.menuDivider} role="separator" />}
|
|
<button
|
|
type="button"
|
|
className={styles.item}
|
|
role="menuitem"
|
|
onClick={() => {
|
|
setOpen(false)
|
|
onAiPrompts()
|
|
}}
|
|
>
|
|
<FileText size={16} />
|
|
<span>AI prompts</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={styles.item}
|
|
role="menuitem"
|
|
onClick={() => {
|
|
setOpen(false)
|
|
onAllUsers()
|
|
}}
|
|
>
|
|
<Users size={16} />
|
|
<span>All users</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`${styles.item} ${styles.itemDanger}`}
|
|
role="menuitem"
|
|
onClick={(e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
setOpen(false)
|
|
onWipeData()
|
|
}}
|
|
>
|
|
<Eraser size={16} />
|
|
<span>Wipe data</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`${styles.item} ${styles.itemDanger}`}
|
|
role="menuitem"
|
|
onClick={() => {
|
|
setOpen(false)
|
|
onRemove()
|
|
}}
|
|
>
|
|
<Trash2 size={16} />
|
|
<span>Remove</span>
|
|
</button>
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
</div>
|
|
)
|
|
}
|