diff --git a/api/partner.js b/api/partner.js
index d61401acf..ae118d3b1 100644
--- a/api/partner.js
+++ b/api/partner.js
@@ -9,3 +9,13 @@ export const getPartnerConnectionListeners = async (connectionId) => {
const response = await axiosAdmin.get(`/partner/connection/${connectionId}/listeners`)
return response.data
}
+
+export const createPartnerConnection = async (data) => {
+ const response = await axiosAdmin.post('/partner/connections', data)
+ return response.data
+}
+
+export const deletePartnerConnection = async (connectionId) => {
+ const response = await axiosAdmin.delete(`/partner/connection/${connectionId}`)
+ return response.data
+}
\ No newline at end of file
diff --git a/components/Admin/BillingCountry.js b/components/Admin/BillingCountry.js
index b7411d9d7..b54aaf691 100644
--- a/components/Admin/BillingCountry.js
+++ b/components/Admin/BillingCountry.js
@@ -92,7 +92,9 @@ export default function BillingCountry({ billingCountry, setBillingCountry, choo
{billingCountry && (
<>
Your billing country is{' '}
- setChoosingCountry(true)}>{countries?.getNameTranslated?.(billingCountry) || billingCountry}
+ setChoosingCountry(true)}>
+ {countries?.getNameTranslated?.(billingCountry) || billingCountry}
+
>
)}
>
diff --git a/components/Admin/notifications/AddChannelButton.js b/components/Admin/notifications/AddChannelButton.js
new file mode 100644
index 000000000..5524a47c8
--- /dev/null
+++ b/components/Admin/notifications/AddChannelButton.js
@@ -0,0 +1,13 @@
+import Link from 'next/link'
+import { FaPlus } from 'react-icons/fa'
+
+export default function AddChannelButton() {
+ return (
+
+
+
+ )
+}
diff --git a/components/Admin/notifications/AddRuleButton.js b/components/Admin/notifications/AddRuleButton.js
new file mode 100644
index 000000000..685254e0d
--- /dev/null
+++ b/components/Admin/notifications/AddRuleButton.js
@@ -0,0 +1,3 @@
+export default function AddRuleButton() {
+ return
+}
diff --git a/components/Admin/notifications/ChannelCard.js b/components/Admin/notifications/ChannelCard.js
deleted file mode 100644
index 079117125..000000000
--- a/components/Admin/notifications/ChannelCard.js
+++ /dev/null
@@ -1,104 +0,0 @@
-import React from 'react'
-
-import { FaSlack, FaDiscord, FaEnvelope } from 'react-icons/fa'
-import { FaXTwitter } from 'react-icons/fa6'
-
-import Card from '@/components/UI/Card'
-
-const iconMap = {
- slack_webhook: FaSlack,
- discord_webhook: FaDiscord,
- twitter_api: FaXTwitter,
- email_webhook: FaEnvelope
-}
-
-const ChannelSpecificDetails = ({ channel }) => {
- if (!channel.settings) {
- return null
- }
- switch (channel.type) {
- case 'slack_webhook':
- return (
-
-
-
-
-
- {t('notifications.empty.title', 'No notification rules or channels yet')}
-
+export default function EmptyState({ action, title, description, showImage = false }) {
+ return (
+
+ {showImage && (
+
+
- )
+ )}
+
{title}
+
{description}
+ {action}
+
+ )
}
diff --git a/components/Admin/notifications/ErrorState.js b/components/Admin/notifications/ErrorState.js
index 8beba52a2..dec862b3e 100644
--- a/components/Admin/notifications/ErrorState.js
+++ b/components/Admin/notifications/ErrorState.js
@@ -2,19 +2,19 @@ import { useTranslation } from 'next-i18next'
import Image from 'next/image'
export default function ErrorState() {
- const { t } = useTranslation('admin')
+ const { t } = useTranslation('admin')
- return (
-
-
-
-
-
- {t('notifications.error.title', 'Error loading notifications')}
-
-
- {t('notifications.error.description', 'Please try again later.')}
-
-
- )
-}
\ No newline at end of file
+ return (
+
+
+
+
+
+ {t('notifications.error.title', 'Error loading notifications')}
+
+
+ {t('notifications.error.description', 'Please try again later.')}
+
+
+ )
+}
diff --git a/components/Admin/notifications/InputField.js b/components/Admin/notifications/InputField.js
new file mode 100644
index 000000000..b98d03ee3
--- /dev/null
+++ b/components/Admin/notifications/InputField.js
@@ -0,0 +1,31 @@
+export const InputField = ({
+ id,
+ label,
+ type = 'text',
+ placeholder,
+ value,
+ onChange,
+ helpText,
+ error,
+ required = false,
+ ...props
+}) => (
+
+
+
+ {helpText &&
{helpText}
}
+ {error &&
{error}
}
+
+)
diff --git a/components/Admin/notifications/RuleCard.js b/components/Admin/notifications/RuleCard.js
index e675d36c0..83a516987 100644
--- a/components/Admin/notifications/RuleCard.js
+++ b/components/Admin/notifications/RuleCard.js
@@ -1,62 +1,60 @@
import Card from '@/components/UI/Card'
const operatorMap = {
- '$eq': 'is',
- '$ne': 'is not',
- '$gt': '>',
- '$gte': '>=',
- '$lt': '<',
- '$lte': '<=',
- '$in': 'in',
- '$nin': 'not in'
-};
+ $eq: 'is',
+ $ne: 'is not',
+ $gt: '>',
+ $gte: '>=',
+ $lt: '<',
+ $lte: '<=',
+ $in: 'in',
+ $nin: 'not in'
+}
function formatCondition(field, opObj) {
- if (typeof opObj !== 'object' || opObj === null) return '';
- return Object.entries(opObj)
- .map(([op, value]) => {
- let opStr = operatorMap[op] || op;
- let valStr = Array.isArray(value) ? `[${value.join(', ')}]` : String(value);
- return `${field} ${opStr} ${valStr}`;
- })
- .join(' and ');
+ if (typeof opObj !== 'object' || opObj === null) return ''
+ return Object.entries(opObj)
+ .map(([op, value]) => {
+ let opStr = operatorMap[op] || op
+ let valStr = Array.isArray(value) ? `[${value.join(', ')}]` : String(value)
+ return `${field} ${opStr} ${valStr}`
+ })
+ .join(' and ')
}
function parseConditions(conditions) {
- if (!conditions || typeof conditions !== 'object') return '';
- let parts = [];
+ if (!conditions || typeof conditions !== 'object') return ''
+ let parts = []
- for (const [key, value] of Object.entries(conditions)) {
- if (key === '$or' && Array.isArray(value)) {
- const orParts = value.map((sub) => parseConditions(sub)).filter(Boolean);
- if (orParts.length) {
- parts.push(`(${orParts.join(' OR ')})`);
- }
- } else if (typeof value === 'object' && value !== null) {
- parts.push(formatCondition(key, value));
- }
+ for (const [key, value] of Object.entries(conditions)) {
+ if (key === '$or' && Array.isArray(value)) {
+ const orParts = value.map((sub) => parseConditions(sub)).filter(Boolean)
+ if (orParts.length) {
+ parts.push(`(${orParts.join(' OR ')})`)
+ }
+ } else if (typeof value === 'object' && value !== null) {
+ parts.push(formatCondition(key, value))
}
+ }
- return parts.filter(Boolean).join(' AND ');
+ return parts.filter(Boolean).join(' AND ')
}
export default function RuleCard({ rule }) {
- return (
-
-
- {rule.name || `Rule #${rule.id}`}
-
-
-
- Event: {rule.event || 'N/A'}
-
-
- Send to: {rule.channel?.name || rule.channel?.type || 'Unknown'}
-
-
-
- If: {parseConditions(rule.settings.rules) || 'N/A'}
-
-
- )
-}
\ No newline at end of file
+ return (
+
+ {rule.name || `Rule #${rule.id}`}
+
+
+ Event: {rule.event || 'N/A'}
+
+
+ Send to: {rule.channel?.name || rule.channel?.type || 'Unknown'}
+
+
+
+ If: {parseConditions(rule.settings.rules) || 'N/A'}
+
+
+ )
+}
diff --git a/components/UI/Dialog.js b/components/UI/Dialog.js
index 35c109c6e..3600d38d0 100644
--- a/components/UI/Dialog.js
+++ b/components/UI/Dialog.js
@@ -94,4 +94,4 @@ export default function Dialog({
// Use portal to render dialog at document root level
return createPortal(dialogContent, portalTarget);
-}
\ No newline at end of file
+}
diff --git a/hooks/useNotifications.js b/hooks/useNotifications.js
index 80976de14..fe2407bdd 100644
--- a/hooks/useNotifications.js
+++ b/hooks/useNotifications.js
@@ -1,65 +1,116 @@
import { useEffect, useState } from 'react'
-import { getPartnerConnections, getPartnerConnectionListeners } from '@/api/partner'
+import {
+ createPartnerConnection,
+ deletePartnerConnection,
+ getPartnerConnectionListeners,
+ getPartnerConnections
+} from '@/api/partner'
-export const useNotifications = () => {
- const [isLoading, setIsLoading] = useState(false)
- const [error, setError] = useState(null)
- const [channels, setChannels] = useState([])
- const [rules, setRules] = useState([])
+export const useGetNotifications = () => {
+ const [isLoading, setIsLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [channels, setChannels] = useState([])
+ const [rules, setRules] = useState([])
- useEffect(() => {
- const fetchAllRules = async () => {
- setIsLoading(true)
+ useEffect(() => {
+ const fetchAllRules = async () => {
+ setIsLoading(true)
+ try {
+ const data = await getPartnerConnections()
+ const connections = data.connections
+ if (!connections || !Array.isArray(connections)) {
+ setRules([])
+ setChannels([])
+ return
+ }
+ // Fetch listeners for all connections concurrently
+ const allRulesPerConnection = await Promise.all(
+ connections.map(async (connection) => {
try {
- const data = await getPartnerConnections()
- const connections = data.connections
- if (!connections || !Array.isArray(connections)) {
- setRules([])
- setChannels([])
- return
- }
- // Fetch listeners for all connections concurrently
- const allRulesPerConnection = await Promise.all(
- connections.map(async (connection) => {
- try {
- const response = await getPartnerConnectionListeners(connection.id)
- const listenersArr = response.listeners || []
- // Attach connection info to each listener
- return {
- ...connection,
- rules: listenersArr.map(listener => ({
- ...listener,
- channel: connection // renamed for clarity
- }))
- }
- } catch (err) {
- // If error, return connection with empty rules
- return {
- ...connection,
- rules: []
- }
- }
- })
- )
- setChannels(allRulesPerConnection)
- // Flatten all rules for the rules state, each rule with its related channel
- setRules(allRulesPerConnection.flatMap(channel => channel.rules))
- } catch (error) {
- setError(error)
- setRules([])
- setChannels([])
- } finally {
- setIsLoading(false)
+ const response = await getPartnerConnectionListeners(connection.id)
+ const listenersArr = response.listeners || []
+ // Attach connection info to each listener
+ return {
+ ...connection,
+ rules: listenersArr.map((listener) => ({
+ ...listener,
+ channel: connection // renamed for clarity
+ }))
+ }
+ } catch (err) {
+ // If error, return connection with empty rules
+ return {
+ ...connection,
+ rules: []
+ }
}
- }
- fetchAllRules()
- }, [])
+ })
+ )
+ setChannels(allRulesPerConnection)
+ // Flatten all rules for the rules state, each rule with its related channel
+ setRules(allRulesPerConnection.flatMap((channel) => channel.rules))
+ } catch (error) {
+ setError(error)
+ setRules([])
+ setChannels([])
+ } finally {
+ setIsLoading(false)
+ }
+ }
+ fetchAllRules()
+ }, [])
+
+ return {
+ data: {
+ rules, // each rule has a 'channel' property
+ channels // each channel has a 'rules' property
+ },
+ isLoading,
+ error
+ }
+}
+
+export const useCreateNotificationChannel = () => {
+ const [isLoading, setIsLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [data, setData] = useState(null)
- return {
- rules, // each rule has a 'channel' property
- channels, // each channel has a 'rules' property
- isLoading,
- error
+ const mutate = async (data) => {
+ setIsLoading(true)
+ try {
+ const response = await createPartnerConnection(data)
+ setData(response)
+ } catch (error) {
+ setError(error)
+ } finally {
+ setIsLoading(false)
}
+ }
+
+ return {
+ data,
+ isLoading,
+ error,
+ mutate
+ }
+}
+
+export const useDeleteNotificationChannel = () => {
+ const [isLoading, setIsLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ const mutate = async (channelId) => {
+ setIsLoading(true)
+ try {
+ await deletePartnerConnection(channelId)
+ // Optionally invalidate/refetch data
+ } catch (error) {
+ setError(error)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return { mutate, isLoading, error }
}
diff --git a/lib/notificationChannels.js b/lib/notificationChannels.js
new file mode 100644
index 000000000..af71ea6e3
--- /dev/null
+++ b/lib/notificationChannels.js
@@ -0,0 +1,89 @@
+import { z } from 'zod'
+
+export const NOTIFICATION_CHANNEL_TYPES = Object.freeze({
+ EMAIL: 'email',
+ SLACK: 'slack_webhook',
+ DISCORD: 'discord_webhook',
+ TWITTER: 'twitter_api',
+})
+
+const slackNotificationChannelSchema = z.object({
+ name: z.string().min(1),
+ webhook: z.string().url(),
+})
+
+const discordNotificationChannelSchema = z.object({
+ name: z.string().min(1),
+ webhook: z.string().url(),
+ username: z.string(),
+})
+
+const emailNotificationChannelSchema = z.object({
+ name: z.string().min(1),
+ email: z.string().email(),
+})
+
+const twitterNotificationChannelSchema = z.object({
+ name: z.string().min(1),
+ consumer_key: z.string().min(1),
+ consumer_secret: z.string().min(1),
+ access_token_key: z.string().min(1),
+ access_token_secret: z.string().min(1),
+})
+
+export const NOTIFICATION_CHANNELS = {
+ [NOTIFICATION_CHANNEL_TYPES.SLACK]: {
+ schema: slackNotificationChannelSchema,
+ fields: [
+ {
+ id: 'webhook',
+ label: 'Webhook URL',
+ placeholder: 'https://hooks.slack.com/services/...',
+ helpText: 'Enter the Slack Incoming Webhook URL.',
+ required: true
+ }
+ ]
+ },
+ [NOTIFICATION_CHANNEL_TYPES.DISCORD]: {
+ schema: discordNotificationChannelSchema,
+ fields: [
+ {
+ id: 'webhook',
+ label: 'Webhook URL',
+ placeholder: 'https://discord.com/api/webhooks/...',
+ helpText: 'Enter the Discord Webhook URL.',
+ required: true
+ },
+ {
+ id: 'username',
+ label: 'Username',
+ placeholder: 'Bithomp Bot',
+ helpText: 'Enter the username for the bot.',
+ required: true
+ }
+ ]
+ },
+ [NOTIFICATION_CHANNEL_TYPES.EMAIL]: {
+ schema: emailNotificationChannelSchema,
+ fields: [
+ {
+ id: 'email',
+ label: 'Email Address',
+ type: 'email',
+ placeholder: 'your-email@example.com',
+ helpText: 'The email address to send notifications to.',
+ required: true
+ }
+ ]
+ },
+ [NOTIFICATION_CHANNEL_TYPES.TWITTER]: {
+ schema: twitterNotificationChannelSchema,
+ fields: [
+ { id: 'consumer_key', label: 'Consumer Key', required: true },
+ { id: 'consumer_secret', label: 'Consumer Secret', type: 'password', required: true },
+ { id: 'access_token_key', label: 'Access Token Key', required: true },
+ { id: 'access_token_secret', label: 'Access Token Secret', type: 'password', required: true }
+ ],
+ helpText: "Your Twitter application's credentials."
+ }
+}
\ No newline at end of file
diff --git a/package.json b/package.json
index 644425c69..ef8f2633a 100644
--- a/package.json
+++ b/package.json
@@ -68,7 +68,8 @@
"tailwindcss": "^4.1.7",
"universal-cookie": "^7.2.1",
"web-vitals": "^2.1.0",
- "xrpl-binary-codec-prerelease": "^8.0.1"
+ "xrpl-binary-codec-prerelease": "^8.0.1",
+ "zod": "^3.25.67"
},
"scripts": {
"dev": "next dev",
diff --git a/pages/admin/notifications/add-channel.js b/pages/admin/notifications/add-channel.js
new file mode 100644
index 000000000..08bb37bc4
--- /dev/null
+++ b/pages/admin/notifications/add-channel.js
@@ -0,0 +1,140 @@
+import { useTranslation } from 'next-i18next'
+import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
+import { useRouter } from 'next/router'
+import { useEffect, useState } from 'react'
+
+import { InputField } from '@/components/Admin/notifications/InputField'
+import AdminTabs from '@/components/Tabs/AdminTabs'
+import { useCreateNotificationChannel } from '@/hooks/useNotifications'
+import { NOTIFICATION_CHANNEL_TYPES, NOTIFICATION_CHANNELS } from '@/lib/notificationChannels'
+
+export async function getServerSideProps({ locale }) {
+ return {
+ props: {
+ ...(await serverSideTranslations(locale, ['common', 'admin']))
+ }
+ }
+}
+
+export default function AddChannel() {
+ const { t } = useTranslation('admin')
+ const [channelType, setChannelType] = useState(NOTIFICATION_CHANNEL_TYPES.SLACK)
+ const [formData, setFormData] = useState({ name: '' })
+ const [errors, setErrors] = useState({})
+ const createChannel = useCreateNotificationChannel()
+ const router = useRouter()
+
+ useEffect(() => {
+ if (createChannel.data) {
+ router.push(`/admin/notifications/`)
+ }
+ }, [createChannel.data, router])
+
+ useEffect(() => {
+ if (createChannel.error) {
+ console.error(createChannel.error)
+ }
+ }, [createChannel.error])
+
+ const handleSubmit = (e) => {
+ e.preventDefault()
+ setErrors({})
+ const parsedData = NOTIFICATION_CHANNELS[channelType].schema.safeParse(formData)
+ if (!parsedData.success) {
+ const fieldErrors = {}
+ for (const issue of parsedData.error.issues) {
+ fieldErrors[issue.path[0]] = issue.message
+ }
+ setErrors(fieldErrors)
+ console.error(parsedData.error)
+ return
+ }
+ const { name, ...settings } = parsedData.data
+ const finalData = {
+ name,
+ type: channelType,
+ settings
+ }
+ createChannel.mutate(finalData)
+ }
+
+ const handleInputChange = (e) => {
+ const { name, value } = e.target
+ setFormData((prev) => ({ ...prev, [name]: value }))
+ if (errors[name]) {
+ setErrors((prev) => ({ ...prev, [name]: null }))
+ }
+ }
+
+ const handleChannelTypeChange = (e) => {
+ const newChannelType = e.target.value
+ setChannelType(newChannelType)
+ const newFormData = { name: formData.name }
+ setFormData(newFormData)
+ setErrors({})
+ }
+
+ const { fields: settingsFields, helpText } = NOTIFICATION_CHANNELS[channelType]
+
+ return (
+
+ {t('header', { ns: 'admin' })}
+
+
+
+ Add a new notification channel to get notified through Slack, Discord, Email, or X (Twitter).
+
+
+
+
+ )
+}
diff --git a/pages/admin/notifications/index.js b/pages/admin/notifications/index.js
index add12ca44..87289365f 100644
--- a/pages/admin/notifications/index.js
+++ b/pages/admin/notifications/index.js
@@ -1,14 +1,16 @@
import { useTranslation } from 'next-i18next'
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
+import AddChannelButton from '@/components/Admin/notifications/AddChannelButton'
+import AddRuleButton from '@/components/Admin/notifications/AddRuleButton'
+import ChannelCard from '@/components/Admin/notifications/ChannelCard'
import EmptyState from '@/components/Admin/notifications/EmptyState'
import ErrorState from '@/components/Admin/notifications/ErrorState'
-import ChannelCard from '@/components/Admin/notifications/ChannelCard'
import RuleCard from '@/components/Admin/notifications/RuleCard'
import AdminTabs from '@/components/Tabs/AdminTabs'
-import { useNotifications } from '@/hooks/useNotifications'
+import { useGetNotifications } from '@/hooks/useNotifications'
-export const getStaticProps = async (context) => {
+export const getServerSideProps = async (context) => {
const { locale } = context
return {
props: {
@@ -19,69 +21,90 @@ export const getStaticProps = async (context) => {
export default function Notifications({ sessionToken, openEmailLogin }) {
const { t } = useTranslation('admin')
- const { rules, channels, isLoading, error } = useNotifications()
+ const notifications = useGetNotifications()
- if (sessionToken && isLoading) {
+ if (sessionToken && notifications.isLoading) {
return
Loading...
}
- if (sessionToken && error) {
+ if (sessionToken && notifications.error) {
return
}
return (
- <>
-
- {t('header', { ns: 'admin' })}
-
+
+ {t('header', { ns: 'admin' })}
+
- {sessionToken ? (
- <>
-
- Set up custom rules to get notified about blockchain events - like NFT listings or high-value sales -
- through Slack, Discord, Email, and more.
-
- {rules.length === 0 && channels.length === 0 && }
+ {sessionToken ? (
+ <>
+
+ Set up custom rules to get notified about blockchain events - like NFT listings or high-value sales -
+ through Slack, Discord, Email, and more.
+
+ {notifications.data.rules.length === 0 && notifications.data.channels.length === 0 && (
+ }
+ showImage={true}
+ />
+ )}
- {rules.length > 0 && (
- <>
+ {notifications.data.channels.length > 0 && notifications.data.rules.length === 0 && (
+ <>
+ }
+ />
+ >
+ )}
+
+ {notifications.data.rules.length > 0 && (
+ <>
+
Your notification rules
-
- {rules.map((rule) => (
-
- ))}
-
- >
- )}
- {channels.length > 0 && (
- <>
-
+
+
+
+ {notifications.data.rules.map((rule) => (
+
+ ))}
+
+ >
+ )}
+ {notifications.data.channels.length > 0 && (
+ <>
+
+
Notification channels
-
- {channels.map((channel) => (
-
- ))}
-
- >
- )}
- >
- ) : (
- <>
-
-
-
-
Set up custom notification rules for blockchain events.
-
Get notified via Slack, Discord, Email and more.
+
-
-
-
-
+
+ {notifications.data.channels.map((channel) => (
+
+ ))}
+
+ >
+ )}
+ >
+ ) : (
+ <>
+
+
+
+
Set up custom notification rules for blockchain events.
+
Get notified via Slack, Discord, Email and more.
- >
- )}
-
- >
+
+
+
+
+
+ >
+ )}
+
)
}
diff --git a/styles/components/dialog.module.scss b/styles/components/dialog.module.scss
new file mode 100644
index 000000000..79a87f4f4
--- /dev/null
+++ b/styles/components/dialog.module.scss
@@ -0,0 +1,163 @@
+.backdrop {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.6);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 9999;
+ padding: 1rem;
+ animation: fadeIn 0.2s ease-out;
+}
+
+.dialog {
+ background: var(--card-bg);
+ color: var(--card-text);
+ border: 2px solid var(--card-border);
+ box-shadow: 4px 4px 0px var(--card-shadow);
+ border-radius: 4px;
+ max-width: 90vw;
+ max-height: 90vh;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ animation: slideIn 0.3s ease-out;
+ position: relative;
+}
+
+.small {
+ width: 400px;
+}
+
+.medium {
+ width: 500px;
+}
+
+.large {
+ width: 700px;
+}
+
+.xlarge {
+ width: 900px;
+}
+
+.header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 1.25rem;
+ border-bottom: 1px solid var(--card-border);
+ margin-bottom: 1rem;
+}
+
+.title {
+ margin: 0;
+ font-size: 1.125rem;
+ font-weight: 600;
+ color: var(--card-text);
+ flex: 1;
+}
+
+.closeButton {
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0.5rem;
+ border-radius: 4px;
+ color: var(--text-secondary);
+ transition: all 0.2s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-left: 0.5rem;
+
+ &:hover {
+ background-color: var(--background-secondary);
+ color: var(--text-main);
+ }
+
+ &:focus {
+ outline: 2px solid var(--accent-link);
+ outline-offset: 2px;
+ }
+}
+
+.content {
+ padding: 0 1.25rem 1.25rem 1.25rem;
+ overflow-y: auto;
+ flex: 1;
+}
+
+// Animations
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes slideIn {
+ from {
+ opacity: 0;
+ transform: scale(0.95) translateY(-20px);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1) translateY(0);
+ }
+}
+
+// Responsive design
+@media (max-width: 768px) {
+ .backdrop {
+ padding: 0.5rem;
+ }
+
+ .dialog {
+ width: 100%;
+ max-width: 100%;
+ max-height: 95vh;
+ margin: 0.5rem;
+ }
+
+ .small,
+ .medium,
+ .large,
+ .xlarge {
+ width: 100%;
+ }
+
+ .header {
+ padding: 0.5rem;
+ }
+
+ .content {
+ padding: 0 1rem 1rem 1rem;
+ }
+
+ .title {
+ font-size: 1rem;
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ .backdrop,
+ .dialog,
+ .header,
+ .content,
+ .title,
+ .closeButton {
+ transition: none;
+ }
+
+ .dialog {
+ animation: none;
+ }
+ }
+}
+
+
\ No newline at end of file
diff --git a/yarn.lock b/yarn.lock
index 8189ee7a7..bd78c5df9 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4584,7 +4584,7 @@ cipher-base@^1.0.1, cipher-base@^1.0.3:
inherits "^2.0.4"
safe-buffer "^5.2.1"
-classnames@^2.2.5, classnames@^2.2.6, classnames@^2.3.2:
+classnames@^2.2.6, classnames@^2.3.2:
version "2.5.1"
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b"
integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==
@@ -5154,11 +5154,6 @@ enhanced-resolve@^5.17.1, enhanced-resolve@^5.18.1:
graceful-fs "^4.2.4"
tapable "^2.2.0"
-enquire.js@^2.1.6:
- version "2.1.6"
- resolved "https://registry.yarnpkg.com/enquire.js/-/enquire.js-2.1.6.tgz#3e8780c9b8b835084c3f60e166dbc3c2a3c89814"
- integrity sha512-/KujNpO+PT63F7Hlpu4h3pE3TokKRHN26JYmQpPyjkRD/N57R7bPDNojMXdi7uveAKjYB7yQnartCxZnFWr0Xw==
-
entities@^4.2.0, entities@^4.4.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48"
@@ -6475,13 +6470,6 @@ json-stable-stringify-without-jsonify@^1.0.1:
resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==
-json2mq@^0.2.0:
- version "0.2.0"
- resolved "https://registry.yarnpkg.com/json2mq/-/json2mq-0.2.0.tgz#b637bd3ba9eabe122c83e9720483aeb10d2c904a"
- integrity sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==
- dependencies:
- string-convert "^0.2.0"
-
json5@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593"
@@ -7611,17 +7599,6 @@ react-share@^5.1.0:
classnames "^2.3.2"
jsonp "^0.2.1"
-react-slick@^0.30.2:
- version "0.30.3"
- resolved "https://registry.yarnpkg.com/react-slick/-/react-slick-0.30.3.tgz#3af5846fcbc04c681f8ba92f48881a0f78124a27"
- integrity sha512-B4x0L9GhkEWUMApeHxr/Ezp2NncpGc+5174R02j+zFiWuYboaq98vmxwlpafZfMjZic1bjdIqqmwLDcQY0QaFA==
- dependencies:
- classnames "^2.2.5"
- enquire.js "^2.1.6"
- json2mq "^0.2.0"
- lodash.debounce "^4.0.8"
- resize-observer-polyfill "^1.5.0"
-
react-transition-group@^4.3.0, react-transition-group@^4.4.5:
version "4.4.5"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1"
@@ -7759,11 +7736,6 @@ require-main-filename@^2.0.0:
resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b"
integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==
-resize-observer-polyfill@^1.5.0:
- version "1.5.1"
- resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464"
- integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==
-
resolve-from@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
@@ -8117,11 +8089,6 @@ sirv@^2.0.3:
mrmime "^2.0.0"
totalist "^3.0.0"
-slick-carousel@^1.8.1:
- version "1.8.1"
- resolved "https://registry.yarnpkg.com/slick-carousel/-/slick-carousel-1.8.1.tgz#a4bfb29014887bb66ce528b90bd0cda262cc8f8d"
- integrity sha512-XB9Ftrf2EEKfzoQXt3Nitrt/IPbT+f1fgqBdoxO3W/+JYvtEOW6EgxnWfr9GH6nmULv7Y2tPmEX3koxThVmebA==
-
smart-buffer@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae"
@@ -8243,11 +8210,6 @@ strict-uri-encode@^2.0.0:
resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546"
integrity sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==
-string-convert@^0.2.0:
- version "0.2.1"
- resolved "https://registry.yarnpkg.com/string-convert/-/string-convert-0.2.1.tgz#6982cc3049fbb4cd85f8b24568b9d9bf39eeff97"
- integrity sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==
-
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
@@ -9207,3 +9169,8 @@ yocto-queue@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
+
+zod@^3.25.67:
+ version "3.25.67"
+ resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.67.tgz#62987e4078e2ab0f63b491ef0c4f33df24236da8"
+ integrity sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==