Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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