Transform qualifying scenarios into marketing-ready use cases with: - AI-generated titles, descriptions, ROI estimates, business value - Persona/industry/category taxonomy for targeting - Browseable feed with filtering and ranking - SEO-optimized case study pages - Daily cron job for generation and ranking Database: - Add Persona, Industry, Category lookup tables - Add UseCase model with marketing content fields - Add junction tables for personas/industries/categories - Add SocialProof model for cached metrics API: - GET /api/use-cases - Global directory with filtering - GET /api/use-cases/[id] - Individual use case details - GET /api/public/users/[username]/collections/[slug]/use-cases - POST /api/cron/use-cases - Nightly generation job Frontend: - /use-cases - Global feed with persona dropdown - /use-cases/[slug] - SEO case study page - /[username]/collections/[slug]/use-cases - Collection feed - UseCasesFeed component - Sortable table component - UseCaseCaseStudy component - Full case study layout
445 lines
12 KiB
TypeScript
445 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import { cn } from '@tpmjs/utils/cn';
|
|
import {
|
|
cloneElement,
|
|
createContext,
|
|
forwardRef,
|
|
isValidElement,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import type {
|
|
DropdownMenuGroupProps,
|
|
DropdownMenuItemProps,
|
|
DropdownMenuLabelProps,
|
|
DropdownMenuPlacement,
|
|
DropdownMenuProps,
|
|
DropdownMenuSeparatorProps,
|
|
} from './types';
|
|
import {
|
|
dropdownMenuContentVariants,
|
|
dropdownMenuItemIconVariants,
|
|
dropdownMenuItemShortcutVariants,
|
|
dropdownMenuItemVariants,
|
|
dropdownMenuLabelVariants,
|
|
dropdownMenuSeparatorVariants,
|
|
} from './variants';
|
|
|
|
// Context for menu state
|
|
interface DropdownMenuContextValue {
|
|
closeMenu: () => void;
|
|
closeOnSelect: boolean;
|
|
activeIndex: number;
|
|
setActiveIndex: (index: number) => void;
|
|
registerItem: () => number;
|
|
}
|
|
|
|
const DropdownMenuContext = createContext<DropdownMenuContextValue | null>(null);
|
|
|
|
/**
|
|
* Calculate position based on trigger and placement
|
|
*/
|
|
function calculatePosition(
|
|
triggerRect: DOMRect,
|
|
contentRect: DOMRect,
|
|
placement: DropdownMenuPlacement,
|
|
offset: number
|
|
): { top: number; left: number } {
|
|
let top = 0;
|
|
let left = 0;
|
|
|
|
const scrollX = window.scrollX;
|
|
const scrollY = window.scrollY;
|
|
|
|
switch (placement) {
|
|
case 'bottom':
|
|
top = triggerRect.bottom + scrollY + offset;
|
|
left = triggerRect.left + scrollX + (triggerRect.width - contentRect.width) / 2;
|
|
break;
|
|
case 'bottom-start':
|
|
top = triggerRect.bottom + scrollY + offset;
|
|
left = triggerRect.left + scrollX;
|
|
break;
|
|
case 'bottom-end':
|
|
top = triggerRect.bottom + scrollY + offset;
|
|
left = triggerRect.right + scrollX - contentRect.width;
|
|
break;
|
|
case 'top':
|
|
top = triggerRect.top + scrollY - contentRect.height - offset;
|
|
left = triggerRect.left + scrollX + (triggerRect.width - contentRect.width) / 2;
|
|
break;
|
|
case 'top-start':
|
|
top = triggerRect.top + scrollY - contentRect.height - offset;
|
|
left = triggerRect.left + scrollX;
|
|
break;
|
|
case 'top-end':
|
|
top = triggerRect.top + scrollY - contentRect.height - offset;
|
|
left = triggerRect.right + scrollX - contentRect.width;
|
|
break;
|
|
}
|
|
|
|
return { top, left };
|
|
}
|
|
|
|
/**
|
|
* DropdownMenu component
|
|
*
|
|
* A menu that appears when clicking a trigger element.
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* import {
|
|
* DropdownMenu,
|
|
* DropdownMenuItem,
|
|
* DropdownMenuSeparator,
|
|
* } from '@tpmjs/ui/DropdownMenu/DropdownMenu';
|
|
* import { Button } from '@tpmjs/ui/Button/Button';
|
|
*
|
|
* function MyComponent() {
|
|
* return (
|
|
* <DropdownMenu trigger={<Button>Open Menu</Button>}>
|
|
* <DropdownMenuItem onSelect={() => console.log('Edit')}>
|
|
* Edit
|
|
* </DropdownMenuItem>
|
|
* <DropdownMenuItem onSelect={() => console.log('Duplicate')}>
|
|
* Duplicate
|
|
* </DropdownMenuItem>
|
|
* <DropdownMenuSeparator />
|
|
* <DropdownMenuItem destructive onSelect={() => console.log('Delete')}>
|
|
* Delete
|
|
* </DropdownMenuItem>
|
|
* </DropdownMenu>
|
|
* );
|
|
* }
|
|
* ```
|
|
*/
|
|
export const DropdownMenu = forwardRef<HTMLDivElement, DropdownMenuProps>(
|
|
(
|
|
{
|
|
open: controlledOpen,
|
|
onOpenChange,
|
|
defaultOpen = false,
|
|
trigger,
|
|
placement = 'bottom-start',
|
|
offset = 4,
|
|
closeOnClickOutside = true,
|
|
closeOnEscape = true,
|
|
closeOnSelect = true,
|
|
children,
|
|
className,
|
|
...props
|
|
},
|
|
ref
|
|
) => {
|
|
const isControlled = controlledOpen !== undefined;
|
|
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
|
const isOpen = isControlled ? controlledOpen : internalOpen;
|
|
|
|
const triggerRef = useRef<HTMLElement>(null);
|
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
const [position, setPosition] = useState({ top: 0, left: 0 });
|
|
const [activeIndex, setActiveIndex] = useState(-1);
|
|
const itemCountRef = useRef(0);
|
|
|
|
const setOpen = useCallback(
|
|
(value: boolean) => {
|
|
if (!isControlled) {
|
|
setInternalOpen(value);
|
|
}
|
|
onOpenChange?.(value);
|
|
if (!value) {
|
|
setActiveIndex(-1);
|
|
}
|
|
},
|
|
[isControlled, onOpenChange]
|
|
);
|
|
|
|
const handleToggle = useCallback(() => {
|
|
setOpen(!isOpen);
|
|
}, [isOpen, setOpen]);
|
|
|
|
const closeMenu = useCallback(() => {
|
|
setOpen(false);
|
|
}, [setOpen]);
|
|
|
|
const registerItem = useCallback(() => {
|
|
const index = itemCountRef.current;
|
|
itemCountRef.current += 1;
|
|
return index;
|
|
}, []);
|
|
|
|
// Reset item count when menu closes
|
|
useEffect(() => {
|
|
if (!isOpen) {
|
|
itemCountRef.current = 0;
|
|
}
|
|
}, [isOpen]);
|
|
|
|
// Update position when open
|
|
useEffect(() => {
|
|
if (!isOpen || !triggerRef.current || !contentRef.current) return;
|
|
|
|
const updatePosition = () => {
|
|
const triggerRect = triggerRef.current?.getBoundingClientRect();
|
|
const contentRect = contentRef.current?.getBoundingClientRect();
|
|
const newPosition = calculatePosition(triggerRect, contentRect, placement, offset);
|
|
setPosition(newPosition);
|
|
};
|
|
|
|
updatePosition();
|
|
|
|
window.addEventListener('scroll', updatePosition, true);
|
|
window.addEventListener('resize', updatePosition);
|
|
|
|
return () => {
|
|
window.removeEventListener('scroll', updatePosition, true);
|
|
window.removeEventListener('resize', updatePosition);
|
|
};
|
|
}, [isOpen, placement, offset]);
|
|
|
|
// Handle click outside
|
|
useEffect(() => {
|
|
if (!isOpen || !closeOnClickOutside) return;
|
|
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
const target = event.target as Node;
|
|
if (triggerRef.current?.contains(target) || contentRef.current?.contains(target)) {
|
|
return;
|
|
}
|
|
closeMenu();
|
|
};
|
|
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, [isOpen, closeOnClickOutside, closeMenu]);
|
|
|
|
// Handle escape key and keyboard navigation
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
switch (event.key) {
|
|
case 'Escape':
|
|
if (closeOnEscape) {
|
|
event.preventDefault();
|
|
closeMenu();
|
|
}
|
|
break;
|
|
case 'ArrowDown':
|
|
event.preventDefault();
|
|
setActiveIndex((prev) => (prev < itemCountRef.current - 1 ? prev + 1 : 0));
|
|
break;
|
|
case 'ArrowUp':
|
|
event.preventDefault();
|
|
setActiveIndex((prev) => (prev > 0 ? prev - 1 : itemCountRef.current - 1));
|
|
break;
|
|
case 'Home':
|
|
event.preventDefault();
|
|
setActiveIndex(0);
|
|
break;
|
|
case 'End':
|
|
event.preventDefault();
|
|
setActiveIndex(itemCountRef.current - 1);
|
|
break;
|
|
}
|
|
};
|
|
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
}, [isOpen, closeOnEscape, closeMenu]);
|
|
|
|
// Clone trigger element with click handler
|
|
const triggerElement = isValidElement(trigger)
|
|
? cloneElement(trigger as React.ReactElement<any>, {
|
|
ref: triggerRef,
|
|
onClick: (e: React.MouseEvent) => {
|
|
(trigger as React.ReactElement<any>).props.onClick?.(e);
|
|
handleToggle();
|
|
},
|
|
'aria-haspopup': 'menu',
|
|
'aria-expanded': isOpen,
|
|
})
|
|
: trigger;
|
|
|
|
const contextValue = useMemo(
|
|
() => ({
|
|
closeMenu,
|
|
closeOnSelect,
|
|
activeIndex,
|
|
setActiveIndex,
|
|
registerItem,
|
|
}),
|
|
[closeMenu, closeOnSelect, activeIndex, registerItem]
|
|
);
|
|
|
|
// Only render portal in browser
|
|
const canRenderPortal = typeof window !== 'undefined';
|
|
|
|
return (
|
|
<DropdownMenuContext.Provider value={contextValue}>
|
|
{triggerElement}
|
|
{canRenderPortal &&
|
|
isOpen &&
|
|
createPortal(
|
|
<div
|
|
ref={(node) => {
|
|
(contentRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
|
if (typeof ref === 'function') {
|
|
ref(node);
|
|
} else if (ref) {
|
|
ref.current = node;
|
|
}
|
|
}}
|
|
role="menu"
|
|
aria-orientation="vertical"
|
|
className={cn(dropdownMenuContentVariants({ state: 'entered' }), className)}
|
|
style={{
|
|
top: position.top,
|
|
left: position.left,
|
|
}}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</div>,
|
|
document.body
|
|
)}
|
|
</DropdownMenuContext.Provider>
|
|
);
|
|
}
|
|
);
|
|
|
|
DropdownMenu.displayName = 'DropdownMenu';
|
|
|
|
/**
|
|
* DropdownMenuItem component
|
|
*/
|
|
export const DropdownMenuItem = forwardRef<HTMLButtonElement, DropdownMenuItemProps>(
|
|
(
|
|
{
|
|
disabled = false,
|
|
destructive = false,
|
|
icon,
|
|
shortcut,
|
|
onSelect,
|
|
children,
|
|
className,
|
|
...props
|
|
},
|
|
ref
|
|
) => {
|
|
const context = useContext(DropdownMenuContext);
|
|
const indexRef = useRef<number>(-1);
|
|
|
|
// Register this item and get its index
|
|
useEffect(() => {
|
|
if (context && indexRef.current === -1) {
|
|
indexRef.current = context.registerItem();
|
|
}
|
|
}, [context]);
|
|
|
|
const isActive = context?.activeIndex === indexRef.current;
|
|
|
|
const handleClick = useCallback(() => {
|
|
if (disabled) return;
|
|
onSelect?.();
|
|
if (context?.closeOnSelect) {
|
|
context.closeMenu();
|
|
}
|
|
}, [disabled, onSelect, context]);
|
|
|
|
const handleKeyDown = useCallback(
|
|
(event: React.KeyboardEvent) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault();
|
|
handleClick();
|
|
}
|
|
},
|
|
[handleClick]
|
|
);
|
|
|
|
return (
|
|
<button
|
|
ref={ref}
|
|
type="button"
|
|
role="menuitem"
|
|
disabled={disabled}
|
|
tabIndex={isActive ? 0 : -1}
|
|
className={cn(
|
|
dropdownMenuItemVariants({
|
|
disabled: disabled ? 'true' : 'false',
|
|
destructive: destructive ? 'true' : 'false',
|
|
active: isActive ? 'true' : 'false',
|
|
}),
|
|
className
|
|
)}
|
|
onClick={handleClick}
|
|
onKeyDown={handleKeyDown}
|
|
onMouseEnter={() => context?.setActiveIndex(indexRef.current)}
|
|
{...props}
|
|
>
|
|
{icon && (
|
|
<span
|
|
className={dropdownMenuItemIconVariants({
|
|
destructive: destructive ? 'true' : 'false',
|
|
})}
|
|
>
|
|
{icon}
|
|
</span>
|
|
)}
|
|
<span className="flex-1">{children}</span>
|
|
{shortcut && <span className={dropdownMenuItemShortcutVariants({})}>{shortcut}</span>}
|
|
</button>
|
|
);
|
|
}
|
|
);
|
|
|
|
DropdownMenuItem.displayName = 'DropdownMenuItem';
|
|
|
|
/**
|
|
* DropdownMenuSeparator component
|
|
*/
|
|
export const DropdownMenuSeparator = forwardRef<HTMLDivElement, DropdownMenuSeparatorProps>(
|
|
({ className, ...props }, ref) => (
|
|
<div
|
|
ref={ref}
|
|
role="separator"
|
|
className={cn(dropdownMenuSeparatorVariants({}), className)}
|
|
{...props}
|
|
/>
|
|
)
|
|
);
|
|
|
|
DropdownMenuSeparator.displayName = 'DropdownMenuSeparator';
|
|
|
|
/**
|
|
* DropdownMenuLabel component
|
|
*/
|
|
export const DropdownMenuLabel = forwardRef<HTMLDivElement, DropdownMenuLabelProps>(
|
|
({ children, className, ...props }, ref) => (
|
|
<div ref={ref} className={cn(dropdownMenuLabelVariants({}), className)} {...props}>
|
|
{children}
|
|
</div>
|
|
)
|
|
);
|
|
|
|
DropdownMenuLabel.displayName = 'DropdownMenuLabel';
|
|
|
|
/**
|
|
* DropdownMenuGroup component
|
|
*/
|
|
export const DropdownMenuGroup = forwardRef<HTMLDivElement, DropdownMenuGroupProps>(
|
|
({ label, children, className, ...props }, ref) => (
|
|
<div ref={ref} role="group" className={className} {...props}>
|
|
{label && <DropdownMenuLabel>{label}</DropdownMenuLabel>}
|
|
{children}
|
|
</div>
|
|
)
|
|
);
|
|
|
|
DropdownMenuGroup.displayName = 'DropdownMenuGroup';
|