Skip to content
70 changes: 70 additions & 0 deletions packages/admin-ui/src/components/CollapsibleList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import React, { useState, ReactNode } from "react";
import { BsChevronDown } from "react-icons/bs";
import "./collapsibleList.scss";

interface CollapsibleListItemProps {
title: string | ReactNode;
children: ReactNode;
isOpen: boolean;
onToggle: () => void;
}

function CollapsibleListItem({ title, children, isOpen, onToggle }: CollapsibleListItemProps) {
return (
<div className={`collapsible-list-item ${isOpen ? "open" : ""}`}>
<button className="collapsible-list-header" onClick={onToggle} type="button">
<span className="collapsible-list-title">{title}</span>
<BsChevronDown className={`collapsible-list-icon ${isOpen ? "rotated" : ""}`} />
</button>
<div className={`collapsible-list-content ${isOpen ? "expanded" : "collapsed"}`}>
<div className="collapsible-list-body">{children}</div>
</div>
</div>
);
}

interface CollapsibleListProps {
items: {
title: string | ReactNode;
content: ReactNode;
}[];
allowMultipleOpen?: boolean;
defaultOpenIndexes?: number[];
}

export function CollapsibleList({ items, allowMultipleOpen = true, defaultOpenIndexes = [] }: CollapsibleListProps) {
const [openIndexes, setOpenIndexes] = useState<Set<number>>(new Set(defaultOpenIndexes));

const toggleSection = (index: number) => {
setOpenIndexes((prev) => {
const newSet = new Set(prev);
if (newSet.has(index)) {
newSet.delete(index);
} else {
if (allowMultipleOpen) {
newSet.add(index);
} else {
// If only single open allowed, clear all and add the new one
newSet.clear();
newSet.add(index);
}
}
return newSet;
});
};

return (
<div className="collapsible-list">
{items.map((item, index) => (
<CollapsibleListItem
key={index}
title={item.title}
isOpen={openIndexes.has(index)}
onToggle={() => toggleSection(index)}
>
{item.content}
</CollapsibleListItem>
))}
</div>
);
}
130 changes: 130 additions & 0 deletions packages/admin-ui/src/components/collapsibleList.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
.collapsible-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}

.collapsible-list-item {
border: 1px solid var(--border-color);
border-radius: 8px;
background-color: var(--background-primary);
overflow: hidden;
transition: all 0.2s ease;

&:hover {
border-color: var(--text-tertiary);
}

&.open {
border-color: var(--primary-color);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
}

.collapsible-list-header {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
background: transparent;
border: none;
cursor: pointer;
transition: background-color 0.2s ease;

&:focus {
outline: 2px solid var(--primary-color);
outline-offset: -2px;
}
}

.collapsible-list-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
text-align: left;
line-height: 1.4;
}

.collapsible-list-icon {
font-size: 1.25rem;
color: var(--text-secondary);
transition: transform 0.3s ease, color 0.2s ease;
flex-shrink: 0;
margin-left: 1rem;

&.rotated {
transform: rotate(180deg);
color: var(--primary-color);
}
}

.collapsible-list-content {
overflow: hidden;
transition: max-height 0.3s ease, opacity 0.3s ease, padding 0.3s ease;

&.collapsed {
max-height: 0;
opacity: 0;
padding: 0 1.25rem;
}

&.expanded {
max-height: 2000px;
opacity: 1;
padding: 0 1.25rem 1rem 1.25rem;
}
}

.collapsible-list-body {
color: var(--text-secondary);
line-height: 1.7;
font-size: 0.95rem;

p {
margin-bottom: 1rem;

&:last-child {
margin-bottom: 0;
}

strong {
font-weight: 600;
color: var(--text-primary);
}
}
}

// Dark mode adjustments - for modals rendered outside #dark
body.dark,
html.dark {
.collapsible-list-item {
background-color: var(--color-dark-card);
border-color: var(--color-dark-border);

&:hover {
border-color: var(--color-dark-secondarytext);
}

&.open {
border-color: var(--color-dark-secondarytext);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.5);
}
}

.collapsible-list-title {
color: var(--color-dark-maintext);
}

.collapsible-list-icon {
color: var(--color-dark-secondarytext);
}

.collapsible-list-body {
color: var(--color-dark-secondarytext);

p strong {
color: var(--color-dark-maintext);
}
}
}
104 changes: 104 additions & 0 deletions packages/admin-ui/src/components/modals/BasePromotionModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import React from "react";
import { externalUrlProps } from "params";
import { Link } from "react-router-dom";

import "./basePromotionModal.scss";

interface BasePromotionModalProps {
show: boolean;
onClose: () => void;
title: string;
description: string;
imageSrc: string;
imageAlt: string;
primaryButtonText: string;
primaryButtonAction: () => void;
secondaryButton?: {
text: string;
} & (
| { type: "action"; action: () => void }
| { type: "internal-link"; to: string }
| { type: "external-link"; href: string; onClick?: () => void }
);
}

export default function BasePromotionModal({
show,
onClose,
title,
description,
imageSrc,
imageAlt,
primaryButtonText,
primaryButtonAction,
secondaryButton
}: BasePromotionModalProps) {
if (!show) return null;

const renderSecondaryButton = () => {
if (!secondaryButton) return null;

const buttonElement = (
<button className="promotion-full-width-button promotion-button-secondary">{secondaryButton.text}</button>
);

switch (secondaryButton.type) {
case "action":
return (
<button className="promotion-full-width-button promotion-button-secondary" onClick={secondaryButton.action}>
{secondaryButton.text}
</button>
);
case "internal-link":
return (
<Link to={secondaryButton.to} className="promotion-link-button">
{buttonElement}
</Link>
);
case "external-link":
return (
<a
href={secondaryButton.href}
{...externalUrlProps}
className="promotion-link-button"
onClick={secondaryButton.onClick}
>
{buttonElement}
</a>
);
default:
return null;
}
};

return (
<div className="promotion-modal-overlay" onClick={onClose}>
<div className="promotion-modal-container" onClick={(e) => e.stopPropagation()}>
<button className="promotion-modal-close" onClick={onClose} aria-label="Close">
×
</button>

<div className="promotion-modal-header">
<h2 className="promotion-modal-title">{title}</h2>
</div>

<div className="promotion-modal-image-container">
<img src={imageSrc} alt={imageAlt} className="promotion-modal-image" />
</div>

<div className="promotion-modal-body">
<div className="promotion-modal-description">
<p>{description}</p>
</div>

<div className="promotion-modal-button-container">
<button className="promotion-full-width-button promotion-button-primary" onClick={primaryButtonAction}>
{primaryButtonText}
</button>
{renderSecondaryButton()}
</div>
</div>
</div>
</div>
);
}
Loading
Loading