From b8f3139e36be0e5dfa50a038397ba11e4d415381 Mon Sep 17 00:00:00 2001 From: Pavel Fokin Date: Thu, 26 Jun 2025 13:51:06 +0200 Subject: [PATCH 1/8] Add basic 'Add Channel' page --- .../Admin/notifications/AddChannelButton.js | 13 ++ components/Admin/notifications/ChannelCard.js | 19 +- components/Admin/notifications/InputField.js | 31 ++++ lib/constants.js | 6 + lib/schemas.js | 24 +++ pages/admin/notifications/add-channel.js | 175 ++++++++++++++++++ pages/admin/notifications/index.js | 10 +- 7 files changed, 266 insertions(+), 12 deletions(-) create mode 100644 components/Admin/notifications/AddChannelButton.js create mode 100644 components/Admin/notifications/InputField.js create mode 100644 lib/constants.js create mode 100644 lib/schemas.js create mode 100644 pages/admin/notifications/add-channel.js diff --git a/components/Admin/notifications/AddChannelButton.js b/components/Admin/notifications/AddChannelButton.js new file mode 100644 index 000000000..a22c78453 --- /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 ( + + + + ) +} \ No newline at end of file diff --git a/components/Admin/notifications/ChannelCard.js b/components/Admin/notifications/ChannelCard.js index 079117125..4df0667c2 100644 --- a/components/Admin/notifications/ChannelCard.js +++ b/components/Admin/notifications/ChannelCard.js @@ -1,15 +1,16 @@ import React from 'react' -import { FaSlack, FaDiscord, FaEnvelope } from 'react-icons/fa' +import { FaDiscord, FaEnvelope, FaSlack } from 'react-icons/fa' import { FaXTwitter } from 'react-icons/fa6' import Card from '@/components/UI/Card' +import { NOTIFICATION_CHANNEL_TYPES } from '@/lib/constants' const iconMap = { - slack_webhook: FaSlack, - discord_webhook: FaDiscord, - twitter_api: FaXTwitter, - email_webhook: FaEnvelope + [NOTIFICATION_CHANNEL_TYPES.SLACK]: FaSlack, + [NOTIFICATION_CHANNEL_TYPES.DISCORD]: FaDiscord, + [NOTIFICATION_CHANNEL_TYPES.TWITTER]: FaXTwitter, + [NOTIFICATION_CHANNEL_TYPES.EMAIL]: FaEnvelope } const ChannelSpecificDetails = ({ channel }) => { @@ -17,13 +18,13 @@ const ChannelSpecificDetails = ({ channel }) => { return null } switch (channel.type) { - case 'slack_webhook': + case NOTIFICATION_CHANNEL_TYPES.SLACK: return (
{channel.settings.webhook || 'N/A'}
) - case 'discord_webhook': + case NOTIFICATION_CHANNEL_TYPES.DISCORD: return (
@@ -39,7 +40,7 @@ const ChannelSpecificDetails = ({ channel }) => {
) - case 'email_webhook': + case NOTIFICATION_CHANNEL_TYPES.EMAIL: return (
@@ -50,7 +51,7 @@ const ChannelSpecificDetails = ({ channel }) => {
) - case 'twitter_api': + case NOTIFICATION_CHANNEL_TYPES.TWITTER: return (
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/lib/constants.js b/lib/constants.js new file mode 100644 index 000000000..83a549b61 --- /dev/null +++ b/lib/constants.js @@ -0,0 +1,6 @@ +export const NOTIFICATION_CHANNEL_TYPES = Object.freeze({ + EMAIL: 'email', + SLACK: 'slack_webhook', + DISCORD: 'discord_webhook', + TWITTER: 'twitter_api', +}) diff --git a/lib/schemas.js b/lib/schemas.js new file mode 100644 index 000000000..a998e1db6 --- /dev/null +++ b/lib/schemas.js @@ -0,0 +1,24 @@ +import { z } from 'zod' + +export const slackNotificationChannelSchema = z.object({ + name: z.string().min(1), + webhook: z.string().url(), +}) + +export const discordNotificationChannelSchema = z.object({ + name: z.string().min(1), + webhook: z.string().url(), +}) + +export const emailNotificationChannelSchema = z.object({ + name: z.string().min(1), + email: z.string().email(), +}) + +export const twitterNotificationChannelSchema = z.object({ + name: z.string().min(1), + consumer_key: z.string(), + consumer_secret: z.string(), + access_token_key: z.string(), + access_token_secret: z.string(), +}) \ No newline at end of file diff --git a/pages/admin/notifications/add-channel.js b/pages/admin/notifications/add-channel.js new file mode 100644 index 000000000..e938ac011 --- /dev/null +++ b/pages/admin/notifications/add-channel.js @@ -0,0 +1,175 @@ +import { useTranslation } from 'next-i18next' +import { serverSideTranslations } from 'next-i18next/serverSideTranslations' +import { useState } from 'react' + +import { InputField } from '@/components/Admin/notifications/InputField' +import AdminTabs from '@/components/Tabs/AdminTabs' +import { NOTIFICATION_CHANNEL_TYPES } from '@/lib/constants' +import { + discordNotificationChannelSchema, + emailNotificationChannelSchema, + slackNotificationChannelSchema, + twitterNotificationChannelSchema +} from '@/lib/schemas' + +export async function getServerSideProps({ locale }) { + return { + props: { + ...(await serverSideTranslations(locale, ['common', 'admin'])) + } + } +} + +const channelSettingsConfig = { + [NOTIFICATION_CHANNEL_TYPES.SLACK]: [ + { + id: 'webhook', + label: 'Webhook URL', + placeholder: 'https://hooks.slack.com/services/...', + helpText: 'Enter the Slack Incoming Webhook URL.', + required: true + } + ], + [NOTIFICATION_CHANNEL_TYPES.DISCORD]: [ + { + 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]: [ + { + 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]: [ + { 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 } + ] +} + +const schema = { + [NOTIFICATION_CHANNEL_TYPES.SLACK]: slackNotificationChannelSchema, + [NOTIFICATION_CHANNEL_TYPES.DISCORD]: discordNotificationChannelSchema, + [NOTIFICATION_CHANNEL_TYPES.EMAIL]: emailNotificationChannelSchema, + [NOTIFICATION_CHANNEL_TYPES.TWITTER]: twitterNotificationChannelSchema +} + +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 handleSubmit = (e) => { + e.preventDefault() + setErrors({}) + const parsedData = schema[channelType].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 finalData = { ...parsedData.data, type: channelType } + console.log(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 settingsFields = channelSettingsConfig[channelType] + + return ( +
+

{t('header', { ns: 'admin' })}

+ +
+

+ Add a new notification channel to get notified through Slack, Discord, Email, or X (Twitter). +

+
+ +
+ + +
+ + {settingsFields.map((field) => ( + + ))} + + {channelType === NOTIFICATION_CHANNEL_TYPES.TWITTER && ( +

Your Twitter application's credentials.

+ )} + + + +
+
+ ) +} diff --git a/pages/admin/notifications/index.js b/pages/admin/notifications/index.js index b9bc45cf4..406dd60fb 100644 --- a/pages/admin/notifications/index.js +++ b/pages/admin/notifications/index.js @@ -1,14 +1,15 @@ import { useTranslation } from 'next-i18next' import { serverSideTranslations } from 'next-i18next/serverSideTranslations' +import AddChannelButton from '@/components/Admin/notifications/AddChannelButton' +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' -export const getStaticProps = async (context) => { +export const getServerSideProps = async (context) => { const { locale } = context return { props: { @@ -51,7 +52,10 @@ export default function Notifications() { {channels.length > 0 && ( <>
-

Notification channels

+
+

Notification channels

+ +
{channels.map((channel) => ( From 89a4328f687d478d74b3a575bb5eb15c1981e249 Mon Sep 17 00:00:00 2001 From: Pavel Fokin Date: Thu, 26 Jun 2025 15:29:47 +0200 Subject: [PATCH 2/8] Add functionality to add a channel --- api/partner.js | 5 + .../Admin/notifications/AddRuleButton.js | 3 + components/Admin/notifications/EmptyState.js | 25 ++-- hooks/useNotifications.js | 139 +++++++++++------- lib/schemas.js | 1 + pages/admin/notifications/add-channel.js | 114 ++++++++------ pages/admin/notifications/index.js | 41 ++++-- 7 files changed, 204 insertions(+), 124 deletions(-) create mode 100644 components/Admin/notifications/AddRuleButton.js diff --git a/api/partner.js b/api/partner.js index d61401acf..212f8cb19 100644 --- a/api/partner.js +++ b/api/partner.js @@ -9,3 +9,8 @@ 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 +} \ No newline at end of file diff --git a/components/Admin/notifications/AddRuleButton.js b/components/Admin/notifications/AddRuleButton.js new file mode 100644 index 000000000..32f06b2e9 --- /dev/null +++ b/components/Admin/notifications/AddRuleButton.js @@ -0,0 +1,3 @@ +export default function AddRuleButton() { + return +} \ No newline at end of file diff --git a/components/Admin/notifications/EmptyState.js b/components/Admin/notifications/EmptyState.js index 909926b3b..fa0ea67f1 100644 --- a/components/Admin/notifications/EmptyState.js +++ b/components/Admin/notifications/EmptyState.js @@ -1,22 +1,21 @@ -import { useTranslation } from 'next-i18next' import Image from 'next/image' -export default function EmptyState() { - const { t } = useTranslation('admin') - +export default function EmptyState({ action, title, description, showImage = false }) { return (
-
- No notifications + No notifications -
-

- {t('notifications.empty.title', 'No notification rules or channels yet')} -

+ /> +
+ )} +

{title}

+

{description}

+ {action}
) } diff --git a/hooks/useNotifications.js b/hooks/useNotifications.js index 80976de14..239994b20 100644 --- a/hooks/useNotifications.js +++ b/hooks/useNotifications.js @@ -1,65 +1,92 @@ import { useEffect, useState } from 'react' -import { getPartnerConnections, getPartnerConnectionListeners } from '@/api/partner' +import { createPartnerConnection, 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 { - rules, // each rule has a 'channel' property - channels, // each channel has a 'rules' property - isLoading, - error + 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) + + 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 + } } diff --git a/lib/schemas.js b/lib/schemas.js index a998e1db6..3c8fb9dfd 100644 --- a/lib/schemas.js +++ b/lib/schemas.js @@ -8,6 +8,7 @@ export const slackNotificationChannelSchema = z.object({ export const discordNotificationChannelSchema = z.object({ name: z.string().min(1), webhook: z.string().url(), + username: z.string(), }) export const emailNotificationChannelSchema = z.object({ diff --git a/pages/admin/notifications/add-channel.js b/pages/admin/notifications/add-channel.js index e938ac011..69b044c92 100644 --- a/pages/admin/notifications/add-channel.js +++ b/pages/admin/notifications/add-channel.js @@ -1,9 +1,11 @@ import { useTranslation } from 'next-i18next' import { serverSideTranslations } from 'next-i18next/serverSideTranslations' -import { useState } from 'react' +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 } from '@/lib/constants' import { discordNotificationChannelSchema, @@ -76,6 +78,20 @@ export default function AddChannel() { 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() @@ -90,8 +106,13 @@ export default function AddChannel() { console.error(parsedData.error) return } - const finalData = { ...parsedData.data, type: channelType } - console.log(finalData) + const { name, ...settings } = parsedData.data + const finalData = { + name, + type: channelType, + settings + } + createChannel.mutate(finalData) } const handleInputChange = (e) => { @@ -121,53 +142,56 @@ export default function AddChannel() { Add a new notification channel to get notified through Slack, Discord, Email, or X (Twitter).

+ +
+ + +
+ + {settingsFields.map((field) => ( -
- - -
- - {settingsFields.map((field) => ( - - ))} + ))} - {channelType === NOTIFICATION_CHANNEL_TYPES.TWITTER && ( -

Your Twitter application's credentials.

- )} + {channelType === NOTIFICATION_CHANNEL_TYPES.TWITTER && ( +

Your Twitter application's credentials.

+ )} - +
diff --git a/pages/admin/notifications/index.js b/pages/admin/notifications/index.js index 406dd60fb..da6aff224 100644 --- a/pages/admin/notifications/index.js +++ b/pages/admin/notifications/index.js @@ -2,12 +2,13 @@ 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 RuleCard from '@/components/Admin/notifications/RuleCard' import AdminTabs from '@/components/Tabs/AdminTabs' -import { useNotifications } from '@/hooks/useNotifications' +import { useGetNotifications } from '@/hooks/useNotifications' export const getServerSideProps = async (context) => { const { locale } = context @@ -20,12 +21,12 @@ export const getServerSideProps = async (context) => { export default function Notifications() { const { t } = useTranslation('admin') - const { rules, channels, isLoading, error } = useNotifications() + const notifications = useGetNotifications() - if (isLoading) { + if (notifications.isLoading) { return
Loading...
} - if (error) { + if (notifications.error) { return } @@ -37,19 +38,39 @@ export default function Notifications() { 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 && } + {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 && ( <> -

Your notification rules

+ } + /> + + )} + + {notifications.data.rules.length > 0 && ( + <> +
+

Your notification rules

+ +
- {rules.map((rule) => ( + {notifications.data.rules.map((rule) => ( ))}
)} - {channels.length > 0 && ( + {notifications.data.channels.length > 0 && ( <>
@@ -57,7 +78,7 @@ export default function Notifications() {
- {channels.map((channel) => ( + {notifications.data.channels.map((channel) => ( ))}
From ac61bdf1a31326f759ee177681e5e046bb632baf Mon Sep 17 00:00:00 2001 From: Pavel Fokin Date: Fri, 27 Jun 2025 09:51:40 +0200 Subject: [PATCH 3/8] Merge notification channel configuration --- lib/notificationChannels.js | 83 ++++++++++++++++++++++++ lib/schemas.js | 25 ------- pages/admin/notifications/add-channel.js | 66 ++----------------- 3 files changed, 87 insertions(+), 87 deletions(-) create mode 100644 lib/notificationChannels.js delete mode 100644 lib/schemas.js diff --git a/lib/notificationChannels.js b/lib/notificationChannels.js new file mode 100644 index 000000000..b0176d93d --- /dev/null +++ b/lib/notificationChannels.js @@ -0,0 +1,83 @@ +import { z } from 'zod' +import { NOTIFICATION_CHANNEL_TYPES } from '@/lib/constants' + +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/lib/schemas.js b/lib/schemas.js deleted file mode 100644 index 3c8fb9dfd..000000000 --- a/lib/schemas.js +++ /dev/null @@ -1,25 +0,0 @@ -import { z } from 'zod' - -export const slackNotificationChannelSchema = z.object({ - name: z.string().min(1), - webhook: z.string().url(), -}) - -export const discordNotificationChannelSchema = z.object({ - name: z.string().min(1), - webhook: z.string().url(), - username: z.string(), -}) - -export const emailNotificationChannelSchema = z.object({ - name: z.string().min(1), - email: z.string().email(), -}) - -export const twitterNotificationChannelSchema = z.object({ - name: z.string().min(1), - consumer_key: z.string(), - consumer_secret: z.string(), - access_token_key: z.string(), - access_token_secret: z.string(), -}) \ No newline at end of file diff --git a/pages/admin/notifications/add-channel.js b/pages/admin/notifications/add-channel.js index 69b044c92..45f407197 100644 --- a/pages/admin/notifications/add-channel.js +++ b/pages/admin/notifications/add-channel.js @@ -7,12 +7,7 @@ import { InputField } from '@/components/Admin/notifications/InputField' import AdminTabs from '@/components/Tabs/AdminTabs' import { useCreateNotificationChannel } from '@/hooks/useNotifications' import { NOTIFICATION_CHANNEL_TYPES } from '@/lib/constants' -import { - discordNotificationChannelSchema, - emailNotificationChannelSchema, - slackNotificationChannelSchema, - twitterNotificationChannelSchema -} from '@/lib/schemas' +import { NOTIFICATION_CHANNELS } from '@/lib/notificationChannels' export async function getServerSideProps({ locale }) { return { @@ -22,57 +17,6 @@ export async function getServerSideProps({ locale }) { } } -const channelSettingsConfig = { - [NOTIFICATION_CHANNEL_TYPES.SLACK]: [ - { - id: 'webhook', - label: 'Webhook URL', - placeholder: 'https://hooks.slack.com/services/...', - helpText: 'Enter the Slack Incoming Webhook URL.', - required: true - } - ], - [NOTIFICATION_CHANNEL_TYPES.DISCORD]: [ - { - 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]: [ - { - 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]: [ - { 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 } - ] -} - -const schema = { - [NOTIFICATION_CHANNEL_TYPES.SLACK]: slackNotificationChannelSchema, - [NOTIFICATION_CHANNEL_TYPES.DISCORD]: discordNotificationChannelSchema, - [NOTIFICATION_CHANNEL_TYPES.EMAIL]: emailNotificationChannelSchema, - [NOTIFICATION_CHANNEL_TYPES.TWITTER]: twitterNotificationChannelSchema -} - export default function AddChannel() { const { t } = useTranslation('admin') const [channelType, setChannelType] = useState(NOTIFICATION_CHANNEL_TYPES.SLACK) @@ -96,7 +40,7 @@ export default function AddChannel() { const handleSubmit = (e) => { e.preventDefault() setErrors({}) - const parsedData = schema[channelType].safeParse(formData) + const parsedData = NOTIFICATION_CHANNELS[channelType].schema.safeParse(formData) if (!parsedData.success) { const fieldErrors = {} for (const issue of parsedData.error.issues) { @@ -131,7 +75,7 @@ export default function AddChannel() { setErrors({}) } - const settingsFields = channelSettingsConfig[channelType] + const { fields: settingsFields, helpText } = NOTIFICATION_CHANNELS[channelType] return (
@@ -185,9 +129,7 @@ export default function AddChannel() { /> ))} - {channelType === NOTIFICATION_CHANNEL_TYPES.TWITTER && ( -

Your Twitter application's credentials.

- )} + {helpText &&

{helpText}

} +
+ +
+ Used in {channel.rules.length} {channel.rules.length === 1 ? 'rule' : 'rules'} +
+ setIsDeleteDialogOpen(false)} + onDelete={handleDelete} + /> + + ) +} diff --git a/components/Admin/notifications/ChannelCard/ChannelDeleteDialog.js b/components/Admin/notifications/ChannelCard/ChannelDeleteDialog.js new file mode 100644 index 000000000..01091c8fe --- /dev/null +++ b/components/Admin/notifications/ChannelCard/ChannelDeleteDialog.js @@ -0,0 +1,23 @@ +import Dialog from '@/components/UI/Dialog' + +export default function ChannelDeleteDialog({ isOpen, onClose, onDelete }) { + return ( + +
+ Are you sure you want to delete this channel? This action cannot be undone. +
+
+ + +
+
+ ) +} diff --git a/components/Admin/notifications/ChannelCard/ChannelSpecificDetails.js b/components/Admin/notifications/ChannelCard/ChannelSpecificDetails.js new file mode 100644 index 000000000..b8787ec62 --- /dev/null +++ b/components/Admin/notifications/ChannelCard/ChannelSpecificDetails.js @@ -0,0 +1,73 @@ +import { NOTIFICATION_CHANNEL_TYPES } from '@/lib/notificationChannels' + +export default function ChannelSpecificDetails({ channel }) { + if (!channel.settings) { + return null + } + switch (channel.type) { + case NOTIFICATION_CHANNEL_TYPES.SLACK: + return ( +
+ {channel.settings.webhook || 'N/A'} +
+ ) + case NOTIFICATION_CHANNEL_TYPES.DISCORD: + return ( +
+
+ + {channel.settings.webhook || 'N/A'} + +
+
+ Username:{' '} + + {channel.settings.username || 'N/A'} + +
+
+ ) + case NOTIFICATION_CHANNEL_TYPES.EMAIL: + return ( +
+
+ Webhook:{' '} + + {channel.settings.webhook || 'N/A'} + +
+
+ ) + case NOTIFICATION_CHANNEL_TYPES.TWITTER: + return ( +
+
+ Consumer key:{' '} + + {channel.settings.consumer_key ? channel.settings.consumer_key.slice(-8) : 'N/A'} + +
+
+ Consumer secret:{' '} + + {channel.settings.consumer_secret ? channel.settings.consumer_secret.slice(-8) : 'N/A'} + +
+
+ Access token key:{' '} + + {channel.settings.access_token_key ? channel.settings.access_token_key.slice(-8) : 'N/A'} + +
+
+ Access token secret: + + {channel.settings.access_token_secret ? channel.settings.access_token_secret.slice(-8) : 'N/A'} + +
+
+ ) + default: + return null + } + } \ No newline at end of file diff --git a/components/Admin/notifications/ChannelCard/index.js b/components/Admin/notifications/ChannelCard/index.js new file mode 100644 index 000000000..83cbfbcf3 --- /dev/null +++ b/components/Admin/notifications/ChannelCard/index.js @@ -0,0 +1 @@ +export { default } from './ChannelCard' \ No newline at end of file diff --git a/components/UI/Dialog.js b/components/UI/Dialog.js new file mode 100644 index 000000000..50dbc4ff2 --- /dev/null +++ b/components/UI/Dialog.js @@ -0,0 +1,119 @@ +import React, { useEffect, useRef } from 'react'; +import styles from '@/styles/components/dialog.module.scss'; + +const Dialog = ({ + isOpen, + onClose, + title, + children, + size = 'medium', + showCloseButton = true, + closeOnBackdropClick = true, + closeOnEscape = true +}) => { + const dialogRef = useRef(null); + const previousActiveElement = useRef(null); + + useEffect(() => { + if (isOpen) { + // Store the currently focused element + previousActiveElement.current = document.activeElement; + + // Focus the dialog + if (dialogRef.current) { + dialogRef.current.focus(); + } + + // Prevent body scroll + document.body.style.overflow = 'hidden'; + } else { + // Restore body scroll + document.body.style.overflow = 'unset'; + + // Restore focus to the previous element + if (previousActiveElement.current) { + previousActiveElement.current.focus(); + } + } + + return () => { + document.body.style.overflow = 'unset'; + }; + }, [isOpen]); + + useEffect(() => { + const handleEscape = (event) => { + if (event.key === 'Escape' && isOpen && closeOnEscape) { + onClose(); + } + }; + + if (isOpen) { + document.addEventListener('keydown', handleEscape); + } + + return () => { + document.removeEventListener('keydown', handleEscape); + }; + }, [isOpen, onClose, closeOnEscape]); + + const handleBackdropClick = (event) => { + if (event.target === event.currentTarget && closeOnBackdropClick) { + onClose(); + } + }; + + if (!isOpen) return null; + + return ( +
+
+
+ {title && ( +

+ {title} +

+ )} + {showCloseButton && ( + + )} +
+
+ {children} +
+
+
+ ); +}; + +export default Dialog; diff --git a/hooks/useNotifications.js b/hooks/useNotifications.js index 239994b20..fe2407bdd 100644 --- a/hooks/useNotifications.js +++ b/hooks/useNotifications.js @@ -1,6 +1,11 @@ import { useEffect, useState } from 'react' -import { createPartnerConnection, getPartnerConnectionListeners, getPartnerConnections } from '@/api/partner' +import { + createPartnerConnection, + deletePartnerConnection, + getPartnerConnectionListeners, + getPartnerConnections +} from '@/api/partner' export const useGetNotifications = () => { const [isLoading, setIsLoading] = useState(false) @@ -90,3 +95,22 @@ export const useCreateNotificationChannel = () => { 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/constants.js b/lib/constants.js deleted file mode 100644 index 83a549b61..000000000 --- a/lib/constants.js +++ /dev/null @@ -1,6 +0,0 @@ -export const NOTIFICATION_CHANNEL_TYPES = Object.freeze({ - EMAIL: 'email', - SLACK: 'slack_webhook', - DISCORD: 'discord_webhook', - TWITTER: 'twitter_api', -}) diff --git a/lib/notificationChannels.js b/lib/notificationChannels.js index b0176d93d..af71ea6e3 100644 --- a/lib/notificationChannels.js +++ b/lib/notificationChannels.js @@ -1,5 +1,11 @@ import { z } from 'zod' -import { NOTIFICATION_CHANNEL_TYPES } from '@/lib/constants' + +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), diff --git a/pages/admin/notifications/add-channel.js b/pages/admin/notifications/add-channel.js index 45f407197..08bb37bc4 100644 --- a/pages/admin/notifications/add-channel.js +++ b/pages/admin/notifications/add-channel.js @@ -6,8 +6,7 @@ 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 } from '@/lib/constants' -import { NOTIFICATION_CHANNELS } from '@/lib/notificationChannels' +import { NOTIFICATION_CHANNEL_TYPES, NOTIFICATION_CHANNELS } from '@/lib/notificationChannels' export async function getServerSideProps({ locale }) { return { diff --git a/styles/components/dialog.module.scss b/styles/components/dialog.module.scss new file mode 100644 index 000000000..64969aef1 --- /dev/null +++ b/styles/components/dialog.module.scss @@ -0,0 +1,168 @@ +.backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; + animation: fadeIn 0.2s ease-out; +} + +.dialog { + background: white; + border-radius: 8px; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + 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: 600px; +} + +.large { + width: 800px; +} + +.xlarge { + width: 1000px; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.5rem 1.5rem 0 1.5rem; + border-bottom: 1px solid #e5e7eb; + margin-bottom: 1rem; +} + +.title { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: #111827; + flex: 1; +} + +.closeButton { + background: none; + border: none; + cursor: pointer; + padding: 0.5rem; + border-radius: 4px; + color: #6b7280; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + margin-left: 0.5rem; + + &:hover { + background-color: #f3f4f6; + color: #374151; + } + + &:focus { + outline: 2px solid #3b82f6; + outline-offset: 2px; + } +} + +.content { + padding: 0 1.5rem 1.5rem 1.5rem; + overflow-y: auto; + flex: 1; +} + +// Animations +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideIn { + from { + opacity: 0; + transform: scale(0.95) translateY(-10px); + } + 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; + } + + .small, + .medium, + .large, + .xlarge { + width: 100%; + } + + .header { + padding: 1rem 1rem 0 1rem; + } + + .content { + padding: 0 1rem 1rem 1rem; + } + + .title { + font-size: 1.125rem; + } +} + +// Dark mode support (if your app supports it) +@media (prefers-color-scheme: dark) { + .dialog { + background: #1f2937; + color: #f9fafb; + } + + .header { + border-bottom-color: #374151; + } + + .title { + color: #f9fafb; + } + + .closeButton { + color: #9ca3af; + + &:hover { + background-color: #374151; + color: #d1d5db; + } + } +} \ No newline at end of file From 8ee458259120e7ac011db464f58f1af9865a033c Mon Sep 17 00:00:00 2001 From: Pavel Fokin Date: Mon, 30 Jun 2025 14:59:48 +0200 Subject: [PATCH 5/8] Add 'zod' package --- .../Admin/notifications/ChannelCard/ChannelCard.js | 11 ++++++----- package.json | 3 ++- pages/admin/notifications/index.js | 2 +- yarn.lock | 5 +++++ 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/components/Admin/notifications/ChannelCard/ChannelCard.js b/components/Admin/notifications/ChannelCard/ChannelCard.js index 10328abc5..52c8c7dfd 100644 --- a/components/Admin/notifications/ChannelCard/ChannelCard.js +++ b/components/Admin/notifications/ChannelCard/ChannelCard.js @@ -36,15 +36,16 @@ export default function ChannelCard({ channel }) { })}{' '} {channel.name || `Channel #${channel.id}`} -
- -
+
Used in {channel.rules.length} {channel.rules.length === 1 ? 'rule' : 'rules'}
+
+ +
setIsDeleteDialogOpen(false)} diff --git a/package.json b/package.json index fa77f5d83..fdfd9a075 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,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/index.js b/pages/admin/notifications/index.js index da6aff224..db910c73a 100644 --- a/pages/admin/notifications/index.js +++ b/pages/admin/notifications/index.js @@ -77,7 +77,7 @@ export default function Notifications() {

Notification channels

-
+
{notifications.data.channels.map((channel) => ( ))} diff --git a/yarn.lock b/yarn.lock index 8189ee7a7..5d8f20919 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9207,3 +9207,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== From 93ee5f0cfb5a909ce98707149fe1e8a137d27af2 Mon Sep 17 00:00:00 2001 From: Pavel Fokin Date: Tue, 1 Jul 2025 10:25:51 +0200 Subject: [PATCH 6/8] Update dialog styles --- .../ChannelCard/ChannelDeleteDialog.js | 17 +++-- styles/components/dialog.module.scss | 66 +++++++------------ 2 files changed, 36 insertions(+), 47 deletions(-) diff --git a/components/Admin/notifications/ChannelCard/ChannelDeleteDialog.js b/components/Admin/notifications/ChannelCard/ChannelDeleteDialog.js index 01091c8fe..fc6550369 100644 --- a/components/Admin/notifications/ChannelCard/ChannelDeleteDialog.js +++ b/components/Admin/notifications/ChannelCard/ChannelDeleteDialog.js @@ -6,15 +6,24 @@ export default function ChannelDeleteDialog({ isOpen, onClose, onDelete }) { isOpen={isOpen} onClose={onClose} title="Delete channel" + size="small" > -
+
Are you sure you want to delete this channel? This action cannot be undone.
-
- -
diff --git a/styles/components/dialog.module.scss b/styles/components/dialog.module.scss index 64969aef1..91ece58e5 100644 --- a/styles/components/dialog.module.scss +++ b/styles/components/dialog.module.scss @@ -4,19 +4,21 @@ left: 0; right: 0; bottom: 0; - background-color: rgba(0, 0, 0, 0.5); + background-color: rgba(0, 0, 0, 0.6); display: flex; align-items: center; justify-content: center; - z-index: 1000; + z-index: 9999; padding: 1rem; animation: fadeIn 0.2s ease-out; } .dialog { - background: white; - border-radius: 8px; - box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + 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; @@ -31,31 +33,31 @@ } .medium { - width: 600px; + width: 500px; } .large { - width: 800px; + width: 700px; } .xlarge { - width: 1000px; + width: 900px; } .header { display: flex; align-items: center; justify-content: space-between; - padding: 1.5rem 1.5rem 0 1.5rem; - border-bottom: 1px solid #e5e7eb; + padding: 1.25rem 1.25rem 0 1.25rem; + border-bottom: 1px solid var(--card-border); margin-bottom: 1rem; } .title { margin: 0; - font-size: 1.25rem; + font-size: 1.125rem; font-weight: 600; - color: #111827; + color: var(--card-text); flex: 1; } @@ -65,7 +67,7 @@ cursor: pointer; padding: 0.5rem; border-radius: 4px; - color: #6b7280; + color: var(--text-secondary); transition: all 0.2s ease; display: flex; align-items: center; @@ -73,18 +75,18 @@ margin-left: 0.5rem; &:hover { - background-color: #f3f4f6; - color: #374151; + background-color: var(--background-secondary); + color: var(--text-main); } &:focus { - outline: 2px solid #3b82f6; + outline: 2px solid var(--accent-link); outline-offset: 2px; } } .content { - padding: 0 1.5rem 1.5rem 1.5rem; + padding: 0 1.25rem 1.25rem 1.25rem; overflow-y: auto; flex: 1; } @@ -102,7 +104,7 @@ @keyframes slideIn { from { opacity: 0; - transform: scale(0.95) translateY(-10px); + transform: scale(0.95) translateY(-20px); } to { opacity: 1; @@ -120,6 +122,7 @@ width: 100%; max-width: 100%; max-height: 95vh; + margin: 0.5rem; } .small, @@ -138,31 +141,8 @@ } .title { - font-size: 1.125rem; + font-size: 1rem; } } -// Dark mode support (if your app supports it) -@media (prefers-color-scheme: dark) { - .dialog { - background: #1f2937; - color: #f9fafb; - } - - .header { - border-bottom-color: #374151; - } - - .title { - color: #f9fafb; - } - - .closeButton { - color: #9ca3af; - - &:hover { - background-color: #374151; - color: #d1d5db; - } - } -} \ No newline at end of file + \ No newline at end of file From 9dcc46db05c9d9da721e66c8137d4716036fb7c0 Mon Sep 17 00:00:00 2001 From: Pavel Fokin Date: Tue, 1 Jul 2025 14:41:39 +0200 Subject: [PATCH 7/8] Fix Dialog positioning --- components/UI/Dialog.js | 5 ++++- styles/components/dialog.module.scss | 19 +++++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/components/UI/Dialog.js b/components/UI/Dialog.js index 50dbc4ff2..72a15d0ae 100644 --- a/components/UI/Dialog.js +++ b/components/UI/Dialog.js @@ -1,4 +1,5 @@ import React, { useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; import styles from '@/styles/components/dialog.module.scss'; const Dialog = ({ @@ -65,7 +66,7 @@ const Dialog = ({ if (!isOpen) return null; - return ( + const dialogContent = (
); + + return createPortal(dialogContent, document.body); }; export default Dialog; diff --git a/styles/components/dialog.module.scss b/styles/components/dialog.module.scss index 91ece58e5..79a87f4f4 100644 --- a/styles/components/dialog.module.scss +++ b/styles/components/dialog.module.scss @@ -48,7 +48,7 @@ display: flex; align-items: center; justify-content: space-between; - padding: 1.25rem 1.25rem 0 1.25rem; + padding: 1.25rem; border-bottom: 1px solid var(--card-border); margin-bottom: 1rem; } @@ -133,7 +133,7 @@ } .header { - padding: 1rem 1rem 0 1rem; + padding: 0.5rem; } .content { @@ -143,6 +143,21 @@ .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 From 28ed5176480b0fc3b168aed11a48f0e618a8d444 Mon Sep 17 00:00:00 2001 From: Viacheslav Bakshaev Date: Sun, 10 Aug 2025 15:25:18 +0300 Subject: [PATCH 8/8] prettify --- components/Admin/BillingCountry.js | 4 +- .../Admin/notifications/AddChannelButton.js | 2 +- .../Admin/notifications/AddRuleButton.js | 2 +- components/Admin/notifications/EmptyState.js | 27 ++-- components/Admin/notifications/ErrorState.js | 30 ++-- components/Admin/notifications/RuleCard.js | 94 +++++++------ pages/admin/notifications/index.js | 129 +++++++++--------- yarn.lock | 40 +----- 8 files changed, 141 insertions(+), 187 deletions(-) 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 index a22c78453..5524a47c8 100644 --- a/components/Admin/notifications/AddChannelButton.js +++ b/components/Admin/notifications/AddChannelButton.js @@ -10,4 +10,4 @@ export default function AddChannelButton() { ) -} \ No newline at end of file +} diff --git a/components/Admin/notifications/AddRuleButton.js b/components/Admin/notifications/AddRuleButton.js index 32f06b2e9..685254e0d 100644 --- a/components/Admin/notifications/AddRuleButton.js +++ b/components/Admin/notifications/AddRuleButton.js @@ -1,3 +1,3 @@ export default function AddRuleButton() { return -} \ No newline at end of file +} diff --git a/components/Admin/notifications/EmptyState.js b/components/Admin/notifications/EmptyState.js index fa0ea67f1..75d910511 100644 --- a/components/Admin/notifications/EmptyState.js +++ b/components/Admin/notifications/EmptyState.js @@ -1,21 +1,16 @@ import Image from 'next/image' export default function EmptyState({ action, title, description, showImage = false }) { - return ( -
- {showImage && ( -
- No notifications -
- )} -

{title}

-

{description}

- {action} + return ( +
+ {showImage && ( +
+ No notifications
- ) + )} +

{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 ( -
-
- Error -
-

- {t('notifications.error.title', 'Error loading notifications')} -

-

- {t('notifications.error.description', 'Please try again later.')} -

-
- ) -} \ No newline at end of file + return ( +
+
+ Error +
+

+ {t('notifications.error.title', 'Error loading notifications')} +

+

+ {t('notifications.error.description', 'Please try again later.')} +

+
+ ) +} 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/pages/admin/notifications/index.js b/pages/admin/notifications/index.js index d10aab1c5..87289365f 100644 --- a/pages/admin/notifications/index.js +++ b/pages/admin/notifications/index.js @@ -34,80 +34,77 @@ export default function Notifications({ sessionToken, openEmailLogin }) {

{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. -

- {notifications.data.rules.length === 0 && notifications.data.channels.length === 0 && ( - } - showImage={true} - /> - )} - {notifications.data.channels.length > 0 && notifications.data.rules.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} + /> + )} - {notifications.data.rules.length > 0 && ( - <> -
-

Your notification rules

- -
-
- {notifications.data.rules.map((rule) => ( - - ))} -
+ {notifications.data.channels.length > 0 && notifications.data.rules.length === 0 && ( + <> + } + /> + + )} + + {notifications.data.rules.length > 0 && ( + <> +
+

Your notification rules

+ +
+
+ {notifications.data.rules.map((rule) => ( + + ))} +
+ + )} + {notifications.data.channels.length > 0 && ( + <> +
+
+

Notification channels

+ +
+
+ {notifications.data.channels.map((channel) => ( + + ))} +
+ + )} - )} - {notifications.data.channels.length > 0 && ( + ) : ( <> -
-
-

Notification channels

- -
-
- {notifications.data.channels.map((channel) => ( - - ))} +
+
+
+

Set up custom notification rules for blockchain events.

+

Get notified via Slack, Discord, Email and more.

+
+
+
+ +
)} - - - ) : ( - <> -
-
-
-

Set up custom notification rules for blockchain events.

-

Get notified via Slack, Discord, Email and more.

-
-
-
- -
-
- - )} -
) } diff --git a/yarn.lock b/yarn.lock index 5d8f20919..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"