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 ( -
- {channel.settings.webhook || 'N/A'} -
- ) - case 'discord_webhook': - return ( -
-
- - {channel.settings.webhook || 'N/A'} - -
-
- Username:{' '} - - {channel.settings.username || 'N/A'} - -
-
- ) - case 'email_webhook': - return ( -
-
- Webhook:{' '} - - {channel.settings.webhook || 'N/A'} - -
-
- ) - case 'twitter_api': - 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 - } -} - -export default function ChannelCard({ channel }) { - return ( - -
- {channel.type && - iconMap[channel.type] && - React.createElement(iconMap[channel.type], { - className: 'inline-block w-4 h-4 text-gray-600 dark:text-gray-400' - })}{' '} - {channel.name || `Channel #${channel.id}`} -
- -
- Used in {channel.rules.length} {channel.rules.length === 1 ? 'rule' : 'rules'} -
-
- ) -} diff --git a/components/Admin/notifications/ChannelCard/ChannelCard.js b/components/Admin/notifications/ChannelCard/ChannelCard.js new file mode 100644 index 000000000..52c8c7dfd --- /dev/null +++ b/components/Admin/notifications/ChannelCard/ChannelCard.js @@ -0,0 +1,56 @@ +import React, { useState } from 'react' + +import { FaDiscord, FaEnvelope, FaSlack } from 'react-icons/fa' +import { FaXTwitter } from 'react-icons/fa6' + +import Card from '@/components/UI/Card' +import { useDeleteNotificationChannel } from '@/hooks/useNotifications' +import { NOTIFICATION_CHANNEL_TYPES } from '@/lib/notificationChannels' + +import ChannelDeleteDialog from './ChannelDeleteDialog' +import ChannelSpecificDetails from './ChannelSpecificDetails' + +const iconMap = { + [NOTIFICATION_CHANNEL_TYPES.SLACK]: FaSlack, + [NOTIFICATION_CHANNEL_TYPES.DISCORD]: FaDiscord, + [NOTIFICATION_CHANNEL_TYPES.TWITTER]: FaXTwitter, + [NOTIFICATION_CHANNEL_TYPES.EMAIL]: FaEnvelope +} + +export default function ChannelCard({ channel }) { + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) + const deleteChannel = useDeleteNotificationChannel() + + const handleDelete = async () => { + await deleteChannel.mutate(channel.id) + setIsDeleteDialogOpen(false) + } + + return ( + +
+ {channel.type && + iconMap[channel.type] && + React.createElement(iconMap[channel.type], { + className: 'inline-block w-4 h-4 text-gray-600 dark:text-gray-400' + })}{' '} + {channel.name || `Channel #${channel.id}`} +
+ + +
+ 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..fc6550369 --- /dev/null +++ b/components/Admin/notifications/ChannelCard/ChannelDeleteDialog.js @@ -0,0 +1,32 @@ +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/Admin/notifications/EmptyState.js b/components/Admin/notifications/EmptyState.js index 909926b3b..75d910511 100644 --- a/components/Admin/notifications/EmptyState.js +++ b/components/Admin/notifications/EmptyState.js @@ -1,22 +1,16 @@ -import { useTranslation } from 'next-i18next' import Image from 'next/image' -export default function EmptyState() { - const { t } = useTranslation('admin') - - return ( -
-
- No notifications -
-

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

+export default function EmptyState({ action, title, description, showImage = false }) { + 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/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). +

+
+ +
+ + +
+ + {settingsFields.map((field) => ( + + ))} + + {helpText &&

{helpText}

} + + + +
+
+ ) +} 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==