(STORAGE_KEY, settings);
+
+ const values = caches === 'cookie' ? cookies : localStorage;
+
+ const [openDrawer, setOpenDrawer] = useState(false);
+
+ const onToggleDrawer = useCallback(() => {
+ setOpenDrawer((prev) => !prev);
+ }, []);
+
+ const onCloseDrawer = useCallback(() => {
+ setOpenDrawer(false);
+ }, []);
+
+ const memoizedValue = useMemo(
+ () => ({
+ ...values.state,
+ canReset: values.canReset,
+ onReset: values.resetState,
+ onUpdate: values.setState,
+ onUpdateField: values.setField,
+ openDrawer,
+ onCloseDrawer,
+ onToggleDrawer,
+ }),
+ [values.canReset, values.resetState, values.setField, values.setState, values.state, openDrawer, onCloseDrawer, onToggleDrawer]
+ );
+
+ return {children};
+}
diff --git a/dashboard/src/components/settings/context/use-settings-context.ts b/dashboard/src/components/settings/context/use-settings-context.ts
new file mode 100644
index 00000000..90c5e4b4
--- /dev/null
+++ b/dashboard/src/components/settings/context/use-settings-context.ts
@@ -0,0 +1,15 @@
+'use client';
+
+import { useContext } from 'react';
+
+import { SettingsContext } from './settings-provider';
+
+// ----------------------------------------------------------------------
+
+export function useSettingsContext() {
+ const context = useContext(SettingsContext);
+
+ if (!context) throw new Error('useSettingsContext must be use inside SettingsProvider');
+
+ return context;
+}
diff --git a/dashboard/src/components/settings/drawer/base-option.tsx b/dashboard/src/components/settings/drawer/base-option.tsx
new file mode 100644
index 00000000..4dc2eb90
--- /dev/null
+++ b/dashboard/src/components/settings/drawer/base-option.tsx
@@ -0,0 +1,74 @@
+import type { ButtonBaseProps } from '@mui/material/ButtonBase';
+
+import Box from '@mui/material/Box';
+import Switch from '@mui/material/Switch';
+import Tooltip from '@mui/material/Tooltip';
+import ButtonBase from '@mui/material/ButtonBase';
+
+import { CONFIG } from 'src/config-global';
+import { varAlpha } from 'src/theme/styles';
+
+import { Iconify } from 'src/components/iconify';
+
+import { SvgColor } from '../../svg-color';
+
+// ----------------------------------------------------------------------
+
+type Props = ButtonBaseProps & {
+ icon: string;
+ label: string;
+ selected: boolean;
+ tooltip?: string;
+};
+
+export function BaseOption({ icon, label, tooltip, selected, ...other }: Props) {
+ return (
+ `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.12)}`,
+ '&:hover': { bgcolor: (theme) => varAlpha(theme.vars.palette.grey['500Channel'], 0.08) },
+ ...(selected && {
+ bgcolor: (theme) => varAlpha(theme.vars.palette.grey['500Channel'], 0.08),
+ }),
+ }}
+ {...other}
+ >
+
+
+
+
+
+
+ theme.typography.pxToRem(13),
+ }}
+ >
+ {label}
+
+
+ {tooltip && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/dashboard/src/components/settings/drawer/font-options.tsx b/dashboard/src/components/settings/drawer/font-options.tsx
new file mode 100644
index 00000000..e505b89c
--- /dev/null
+++ b/dashboard/src/components/settings/drawer/font-options.tsx
@@ -0,0 +1,75 @@
+import Box from '@mui/material/Box';
+import ButtonBase from '@mui/material/ButtonBase';
+
+import { CONFIG } from 'src/config-global';
+import { setFont, varAlpha, stylesMode } from 'src/theme/styles';
+
+import { Block } from './styles';
+import { SvgColor } from '../../svg-color';
+
+// ----------------------------------------------------------------------
+
+type Props = {
+ value: string;
+ options: string[];
+ onClickOption: (newValue: string) => void;
+};
+
+export function FontOptions({ value, options, onClickOption }: Props) {
+ return (
+
+
+ {options.map((option) => {
+ const selected = value === option;
+
+ return (
+
+ onClickOption(option)}
+ sx={{
+ py: 2,
+ width: 1,
+ gap: 0.75,
+ borderWidth: 1,
+ borderRadius: 1.5,
+ borderStyle: 'solid',
+ display: 'inline-flex',
+ flexDirection: 'column',
+ borderColor: 'transparent',
+ fontFamily: setFont(option),
+ fontWeight: 'fontWeightMedium',
+ fontSize: (theme) => theme.typography.pxToRem(12),
+ color: (theme) => theme.vars.palette.text.disabled,
+ ...(selected && {
+ color: (theme) => theme.vars.palette.text.primary,
+ borderColor: (theme) => varAlpha(theme.vars.palette.grey['500Channel'], 0.08),
+ boxShadow: (theme) => `-8px 8px 20px -4px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.12)}`,
+ [stylesMode.dark]: {
+ boxShadow: (theme) => `-8px 8px 20px -4px ${varAlpha(theme.vars.palette.common.blackChannel, 0.12)}`,
+ },
+ }),
+ }}
+ >
+
+ `linear-gradient(135deg, ${theme.vars.palette.primary.light}, ${theme.vars.palette.primary.main})`,
+ }),
+ }}
+ />
+
+ {option}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/dashboard/src/components/settings/drawer/fullscreen-button.tsx b/dashboard/src/components/settings/drawer/fullscreen-button.tsx
new file mode 100644
index 00000000..0cdf1bda
--- /dev/null
+++ b/dashboard/src/components/settings/drawer/fullscreen-button.tsx
@@ -0,0 +1,48 @@
+'use client';
+
+import { useState, useCallback } from 'react';
+
+import Tooltip from '@mui/material/Tooltip';
+import IconButton from '@mui/material/IconButton';
+
+import { CONFIG } from 'src/config-global';
+
+import { SvgColor, svgColorClasses } from '../../svg-color';
+
+// ----------------------------------------------------------------------
+
+export function FullScreenButton() {
+ const [fullscreen, setFullscreen] = useState(false);
+
+ const onToggleFullScreen = useCallback(() => {
+ if (!document.fullscreenElement) {
+ document.documentElement.requestFullscreen();
+ setFullscreen(true);
+ } else if (document.exitFullscreen) {
+ document.exitFullscreen();
+ setFullscreen(false);
+ }
+ }, []);
+
+ return (
+
+ `linear-gradient(135deg, ${theme.vars.palette.grey[500]} 0%, ${theme.vars.palette.grey[600]} 100%)`,
+ ...(fullscreen && {
+ background: (theme) =>
+ `linear-gradient(135deg, ${theme.vars.palette.primary.light} 0%, ${theme.vars.palette.primary.main} 100%)`,
+ }),
+ },
+ }}
+ >
+
+
+
+ );
+}
diff --git a/dashboard/src/components/settings/drawer/index.ts b/dashboard/src/components/settings/drawer/index.ts
new file mode 100644
index 00000000..6bf08164
--- /dev/null
+++ b/dashboard/src/components/settings/drawer/index.ts
@@ -0,0 +1 @@
+export * from './settings-drawer';
diff --git a/dashboard/src/components/settings/drawer/nav-options.tsx b/dashboard/src/components/settings/drawer/nav-options.tsx
new file mode 100644
index 00000000..ec71150e
--- /dev/null
+++ b/dashboard/src/components/settings/drawer/nav-options.tsx
@@ -0,0 +1,258 @@
+import type { ButtonBaseProps } from '@mui/material/ButtonBase';
+
+import Box from '@mui/material/Box';
+import Stack from '@mui/material/Stack';
+import { useTheme } from '@mui/material/styles';
+import ButtonBase from '@mui/material/ButtonBase';
+
+import { CONFIG } from 'src/config-global';
+import { varAlpha, stylesMode } from 'src/theme/styles';
+
+import { Block } from './styles';
+import { SvgColor, svgColorClasses } from '../../svg-color';
+
+import type { SettingsState } from '../types';
+
+// ----------------------------------------------------------------------
+
+type Props = {
+ value: {
+ color: SettingsState['navColor'];
+ layout: SettingsState['navLayout'];
+ };
+ options: {
+ colors: SettingsState['navColor'][];
+ layouts: SettingsState['navLayout'][];
+ };
+ onClickOption: {
+ color: (newValue: SettingsState['navColor']) => void;
+ layout: (newValue: SettingsState['navLayout']) => void;
+ };
+ hideNavColor?: boolean;
+ hideNavLayout?: boolean;
+};
+
+export function NavOptions({ options, value, onClickOption, hideNavColor, hideNavLayout }: Props) {
+ const theme = useTheme();
+
+ const cssVars = {
+ '--item-radius': '12px',
+ '--item-bg': theme.vars.palette.grey[500],
+ '--item-border-color': varAlpha(theme.vars.palette.grey['500Channel'], 0.08),
+ '--item-active-color': `linear-gradient(135deg, ${theme.vars.palette.primary.light} 0%, ${theme.vars.palette.primary.main} 100%)`,
+ '--item-active-shadow-light': `-8px 8px 20px -4px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.12)}`,
+ '--item-active-shadow-dark': `-8px 8px 20px -4px ${varAlpha(theme.vars.palette.common.blackChannel, 0.12)}`,
+ };
+
+ const labelStyles: React.CSSProperties = {
+ display: 'block',
+ lineHeight: '14px',
+ color: 'text.secondary',
+ fontWeight: 'fontWeightSemiBold',
+ fontSize: theme.typography.pxToRem(11),
+ };
+
+ const renderLayout = (
+
+
+ Layout
+
+
+ {options.layouts.map((option) => (
+ onClickOption.layout(option)} />
+ ))}
+
+
+ );
+
+ const renderColor = (
+
+
+ Color
+
+
+ {options.colors.map((option) => (
+ onClickOption.color(option)} />
+ ))}
+
+
+ );
+
+ return (
+
+ {!hideNavLayout && renderLayout}
+ {!hideNavColor && renderColor}
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+type OptionProps = ButtonBaseProps & {
+ option: string;
+ selected: boolean;
+};
+
+export function LayoutOption({ option, selected, sx, ...other }: OptionProps) {
+ const renderNav = () => {
+ const baseStyles = { flexShrink: 0, borderRadius: 1, bgcolor: 'var(--item-bg)' };
+
+ const circle = (
+
+ );
+
+ const primaryItem = (
+
+ );
+
+ const secondaryItem = (
+
+ );
+
+ return (
+
+ {circle}
+ {primaryItem}
+ {secondaryItem}
+
+ );
+ };
+
+ const renderContent = (
+
+
+
+ );
+
+ return (
+
+ {renderNav()}
+ {renderContent}
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+export function ColorOption({ option, selected, sx, ...other }: OptionProps) {
+ return (
+
+
+
+ theme.typography.pxToRem(13),
+ }}
+ >
+ {option}
+
+
+ );
+}
diff --git a/dashboard/src/components/settings/drawer/presets-options.tsx b/dashboard/src/components/settings/drawer/presets-options.tsx
new file mode 100644
index 00000000..2f0efbb3
--- /dev/null
+++ b/dashboard/src/components/settings/drawer/presets-options.tsx
@@ -0,0 +1,54 @@
+import Box from '@mui/material/Box';
+import ButtonBase from '@mui/material/ButtonBase';
+import { alpha as hexAlpha } from '@mui/material/styles';
+
+import { CONFIG } from 'src/config-global';
+
+import { Block } from './styles';
+import { SvgColor } from '../../svg-color';
+
+import type { SettingsState } from '../types';
+
+// ----------------------------------------------------------------------
+
+type Value = SettingsState['primaryColor'];
+
+type Props = {
+ value: Value;
+ options: { name: Value; value: string }[];
+ onClickOption: (newValue: Value) => void;
+};
+
+export function PresetsOptions({ value, options, onClickOption }: Props) {
+ return (
+
+
+ {options.map((option) => {
+ const selected = value === option.name;
+
+ return (
+
+ onClickOption(option.name)}
+ sx={{
+ width: 1,
+ height: 64,
+ borderRadius: 1.5,
+ color: option.value,
+ ...(selected && {
+ bgcolor: hexAlpha(option.value, 0.08),
+ }),
+ }}
+ >
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/dashboard/src/components/settings/drawer/settings-drawer.tsx b/dashboard/src/components/settings/drawer/settings-drawer.tsx
new file mode 100644
index 00000000..55173a6e
--- /dev/null
+++ b/dashboard/src/components/settings/drawer/settings-drawer.tsx
@@ -0,0 +1,193 @@
+'use client';
+
+import Box from '@mui/material/Box';
+import Stack from '@mui/material/Stack';
+import Badge from '@mui/material/Badge';
+import Tooltip from '@mui/material/Tooltip';
+import IconButton from '@mui/material/IconButton';
+import Typography from '@mui/material/Typography';
+import Drawer, { drawerClasses } from '@mui/material/Drawer';
+import { useTheme, useColorScheme } from '@mui/material/styles';
+
+import COLORS from 'src/theme/core/colors.json';
+import { paper, varAlpha } from 'src/theme/styles';
+import { defaultFont } from 'src/theme/core/typography';
+import PRIMARY_COLOR from 'src/theme/with-settings/primary-color.json';
+
+import { Iconify } from '../../iconify';
+import { BaseOption } from './base-option';
+import { NavOptions } from './nav-options';
+import { Scrollbar } from '../../scrollbar';
+import { FontOptions } from './font-options';
+import { useSettingsContext } from '../context';
+import { PresetsOptions } from './presets-options';
+import { defaultSettings } from '../config-settings';
+import { FullScreenButton } from './fullscreen-button';
+
+import type { SettingsDrawerProps } from '../types';
+
+// ----------------------------------------------------------------------
+
+export function SettingsDrawer({
+ sx,
+ hideFont,
+ hideCompact,
+ hidePresets,
+ hideNavColor,
+ hideContrast,
+ hideNavLayout,
+ hideDirection,
+ hideColorScheme,
+}: SettingsDrawerProps) {
+ const theme = useTheme();
+
+ const settings = useSettingsContext();
+
+ const { mode, setMode } = useColorScheme();
+
+ const renderHead = (
+
+
+ Theme
+
+
+
+
+
+ {
+ settings.onReset();
+ setMode(defaultSettings.colorScheme);
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+
+ const renderMode = (
+ {
+ settings.onUpdateField('colorScheme', mode === 'light' ? 'dark' : 'light');
+ setMode(mode === 'light' ? 'dark' : 'light');
+ }}
+ />
+ );
+
+ const renderContrast = (
+ settings.onUpdateField('contrast', settings.contrast === 'default' ? 'hight' : 'default')}
+ />
+ );
+
+ const renderRTL = (
+ settings.onUpdateField('direction', settings.direction === 'ltr' ? 'rtl' : 'ltr')}
+ />
+ );
+
+ const renderCompact = (
+ settings.onUpdateField('compactLayout', !settings.compactLayout)}
+ />
+ );
+
+ const renderPresets = (
+ settings.onUpdateField('primaryColor', newValue)}
+ options={[
+ { name: 'default', value: COLORS.primary.main },
+ { name: 'cyan', value: PRIMARY_COLOR.cyan.main },
+ { name: 'purple', value: PRIMARY_COLOR.purple.main },
+ { name: 'blue', value: PRIMARY_COLOR.blue.main },
+ { name: 'orange', value: PRIMARY_COLOR.orange.main },
+ { name: 'red', value: PRIMARY_COLOR.red.main },
+ ]}
+ />
+ );
+
+ const renderNav = (
+ settings.onUpdateField('navColor', newValue),
+ layout: (newValue) => settings.onUpdateField('navLayout', newValue),
+ }}
+ options={{
+ colors: ['integrate', 'apparent'],
+ layouts: ['vertical', 'horizontal', 'mini'],
+ }}
+ hideNavColor={hideNavColor}
+ hideNavLayout={hideNavLayout}
+ />
+ );
+
+ const renderFont = (
+ settings.onUpdateField('fontFamily', newValue)}
+ options={[defaultFont, 'Inter']}
+ />
+ );
+
+ return (
+
+ {renderHead}
+
+
+
+
+ {!hideColorScheme && renderMode}
+ {!hideContrast && renderContrast}
+ {!hideDirection && renderRTL}
+ {!hideCompact && renderCompact}
+
+ {!(hideNavLayout && hideNavColor) && renderNav}
+ {!hidePresets && renderPresets}
+ {!hideFont && renderFont}
+
+
+
+ );
+}
diff --git a/dashboard/src/components/settings/drawer/styles.tsx b/dashboard/src/components/settings/drawer/styles.tsx
new file mode 100644
index 00000000..f192ddc5
--- /dev/null
+++ b/dashboard/src/components/settings/drawer/styles.tsx
@@ -0,0 +1,63 @@
+import type { Theme, SxProps } from '@mui/material/styles';
+
+import Box from '@mui/material/Box';
+import Tooltip from '@mui/material/Tooltip';
+
+import { varAlpha, stylesMode } from 'src/theme/styles';
+
+import { Iconify } from 'src/components/iconify';
+
+// ----------------------------------------------------------------------
+
+type Props = {
+ title: string;
+ tooltip?: string;
+ sx?: SxProps;
+ children: React.ReactNode;
+};
+
+export function Block({ title, tooltip, children, sx }: Props) {
+ return (
+ `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.12)}`,
+ ...sx,
+ }}
+ >
+
+ {title}
+
+ {tooltip && (
+
+
+
+ )}
+
+
+ {children}
+
+ );
+}
diff --git a/dashboard/src/components/settings/index.ts b/dashboard/src/components/settings/index.ts
new file mode 100644
index 00000000..22824208
--- /dev/null
+++ b/dashboard/src/components/settings/index.ts
@@ -0,0 +1,7 @@
+export * from './drawer';
+
+export * from './context';
+
+export type * from './types';
+
+export * from './config-settings';
diff --git a/dashboard/src/components/settings/server.ts b/dashboard/src/components/settings/server.ts
new file mode 100644
index 00000000..0cab1462
--- /dev/null
+++ b/dashboard/src/components/settings/server.ts
@@ -0,0 +1,13 @@
+import { cookies } from 'next/headers';
+
+import { STORAGE_KEY, defaultSettings } from './config-settings';
+
+// ----------------------------------------------------------------------
+
+export async function detectSettings() {
+ const cookieStore = cookies();
+
+ const settingsStore = cookieStore.get(STORAGE_KEY);
+
+ return settingsStore ? JSON.parse(settingsStore?.value) : defaultSettings;
+}
diff --git a/dashboard/src/components/settings/types.ts b/dashboard/src/components/settings/types.ts
new file mode 100644
index 00000000..f3e70b63
--- /dev/null
+++ b/dashboard/src/components/settings/types.ts
@@ -0,0 +1,48 @@
+import type { CurrencyValue } from 'src/types/currency';
+import type { Theme, SxProps } from '@mui/material/styles';
+import type { ThemeDirection, ThemeColorScheme } from 'src/theme/types';
+
+// ----------------------------------------------------------------------
+
+export type SettingsCaches = 'localStorage' | 'cookie';
+
+export type SettingsDrawerProps = {
+ sx?: SxProps;
+ hideFont?: boolean;
+ hideCompact?: boolean;
+ hidePresets?: boolean;
+ hideNavColor?: boolean;
+ hideContrast?: boolean;
+ hideDirection?: boolean;
+ hideNavLayout?: boolean;
+ hideColorScheme?: boolean;
+};
+
+export type SettingsState = {
+ fontFamily: string;
+ compactLayout: boolean;
+ direction: ThemeDirection;
+ colorScheme: ThemeColorScheme;
+ contrast: 'default' | 'hight';
+ navColor: 'integrate' | 'apparent';
+ navLayout: 'vertical' | 'horizontal' | 'mini';
+ primaryColor: 'default' | 'cyan' | 'purple' | 'blue' | 'orange' | 'red';
+ currency: CurrencyValue;
+};
+
+export type SettingsContextValue = SettingsState & {
+ canReset: boolean;
+ onReset: () => void;
+ onUpdate: (updateValue: Partial) => void;
+ onUpdateField: (name: keyof SettingsState, updateValue: SettingsState[keyof SettingsState]) => void;
+ // Drawer
+ openDrawer: boolean;
+ onCloseDrawer: () => void;
+ onToggleDrawer: () => void;
+};
+
+export type SettingsProviderProps = {
+ settings: SettingsState;
+ caches?: SettingsCaches;
+ children: React.ReactNode;
+};
diff --git a/dashboard/src/components/snackbar/classes.ts b/dashboard/src/components/snackbar/classes.ts
new file mode 100644
index 00000000..e4c0e519
--- /dev/null
+++ b/dashboard/src/components/snackbar/classes.ts
@@ -0,0 +1,25 @@
+// ----------------------------------------------------------------------
+
+export const toasterClasses = {
+ root: 'toaster__root',
+ toast: 'toaster__toast',
+ title: 'toaster__title',
+ icon: 'toaster__icon',
+ iconSvg: 'toaster__icon__svg',
+ content: 'toaster__content',
+ description: 'toaster__description',
+ actionButton: 'toaster__action__button',
+ cancelButton: 'toaster__cancel__button',
+ closeButton: 'toaster__close_button',
+ loadingIcon: 'toaster__loading_icon',
+ //
+ default: 'toaster__default',
+ error: 'toaster__error',
+ success: 'toaster__success',
+ warning: 'toaster__warning',
+ info: 'toaster__info',
+ //
+ loader: 'sonner-loader',
+ loaderVisible: '&[data-visible="true"]',
+ closeBtnVisible: '[data-close-button="true"]',
+};
diff --git a/dashboard/src/components/snackbar/index.ts b/dashboard/src/components/snackbar/index.ts
new file mode 100644
index 00000000..801d8ecd
--- /dev/null
+++ b/dashboard/src/components/snackbar/index.ts
@@ -0,0 +1,3 @@
+export * from 'sonner';
+
+export * from './snackbar';
diff --git a/dashboard/src/components/snackbar/snackbar.tsx b/dashboard/src/components/snackbar/snackbar.tsx
new file mode 100644
index 00000000..cc95e490
--- /dev/null
+++ b/dashboard/src/components/snackbar/snackbar.tsx
@@ -0,0 +1,53 @@
+'use client';
+
+import Portal from '@mui/material/Portal';
+
+import { Iconify } from '../iconify';
+import { StyledToaster } from './styles';
+import { toasterClasses } from './classes';
+
+// ----------------------------------------------------------------------
+
+export function Snackbar() {
+ return (
+
+ ,
+ info: ,
+ success: ,
+ warning: ,
+ error: ,
+ }}
+ />
+
+ );
+}
diff --git a/dashboard/src/components/snackbar/styles.tsx b/dashboard/src/components/snackbar/styles.tsx
new file mode 100644
index 00000000..2c4660b7
--- /dev/null
+++ b/dashboard/src/components/snackbar/styles.tsx
@@ -0,0 +1,178 @@
+import { Toaster } from 'sonner';
+
+import { styled } from '@mui/material/styles';
+
+import { varAlpha } from 'src/theme/styles';
+
+import { toasterClasses } from './classes';
+
+// ----------------------------------------------------------------------
+
+export const StyledToaster = styled(Toaster)(({ theme }) => {
+ const baseStyles = {
+ toastDefault: {
+ padding: theme.spacing(1, 1, 1, 1.5),
+ boxShadow: theme.customShadows.z8,
+ color: theme.vars.palette.background.paper,
+ backgroundColor: theme.vars.palette.text.primary,
+ },
+ toastColor: {
+ padding: theme.spacing(0.5, 1, 0.5, 0.5),
+ boxShadow: theme.customShadows.z8,
+ color: theme.vars.palette.text.primary,
+ backgroundColor: theme.vars.palette.background.paper,
+ },
+ toastLoader: {
+ padding: theme.spacing(0.5, 1, 0.5, 0.5),
+ boxShadow: theme.customShadows.z8,
+ color: theme.vars.palette.text.primary,
+ backgroundColor: theme.vars.palette.background.paper,
+ },
+ };
+
+ const loadingStyles = {
+ top: 0,
+ left: 0,
+ width: '100%',
+ height: '100%',
+ display: 'none',
+ transform: 'none',
+ overflow: 'hidden',
+ alignItems: 'center',
+ position: 'relative',
+ borderRadius: 'inherit',
+ justifyContent: 'center',
+ background: theme.vars.palette.background.neutral,
+ [`& .${toasterClasses.loadingIcon}`]: {
+ zIndex: 9,
+ width: 24,
+ height: 24,
+ borderRadius: '50%',
+ animation: 'rotate 3s infinite linear',
+ background: `conic-gradient(${varAlpha(theme.vars.palette.text.primaryChannel, 0)}, ${varAlpha(theme.vars.palette.text.disabledChannel, 0.64)})`,
+ },
+ [toasterClasses.loaderVisible]: { display: 'flex' },
+ };
+
+ return {
+ width: 300,
+ [`& .${toasterClasses.toast}`]: {
+ gap: 12,
+ width: '100%',
+ minHeight: 52,
+ display: 'flex',
+ borderRadius: 12,
+ alignItems: 'center',
+ },
+ /*
+ * Content
+ */
+ [`& .${toasterClasses.content}`]: {
+ gap: 0,
+ flex: '1 1 auto',
+ },
+ [`& .${toasterClasses.title}`]: {
+ fontSize: theme.typography.subtitle2.fontSize,
+ },
+ [`& .${toasterClasses.description}`]: {
+ ...theme.typography.caption,
+ opacity: 0.64,
+ },
+ /*
+ * Buttons
+ */
+ [`& .${toasterClasses.actionButton}`]: {},
+ [`& .${toasterClasses.cancelButton}`]: {},
+ [`& .${toasterClasses.closeButton}`]: {
+ top: 0,
+ right: 0,
+ left: 'auto',
+ color: 'currentColor',
+ backgroundColor: 'transparent',
+ transform: 'translate(-6px, 6px)',
+ borderColor: varAlpha(theme.vars.palette.grey['500Channel'], 0.16),
+ transition: theme.transitions.create(['background-color', 'border-color']),
+ '&:hover': {
+ borderColor: varAlpha(theme.vars.palette.grey['500Channel'], 0.24),
+ backgroundColor: varAlpha(theme.vars.palette.grey['500Channel'], 0.08),
+ },
+ },
+ /*
+ * Icon
+ */
+ [`& .${toasterClasses.icon}`]: {
+ margin: 0,
+ width: 48,
+ height: 48,
+ alignItems: 'center',
+ borderRadius: 'inherit',
+ justifyContent: 'center',
+ alignSelf: 'flex-start',
+ [`& .${toasterClasses.iconSvg}`]: {
+ width: 24,
+ height: 24,
+ fontSize: 0,
+ },
+ },
+
+ /*
+ * Default
+ */
+ '@keyframes rotate': { to: { transform: 'rotate(1turn)' } },
+
+ [`& .${toasterClasses.default}`]: {
+ ...baseStyles.toastDefault,
+ [`&:has(${toasterClasses.closeBtnVisible})`]: {
+ [`& .${toasterClasses.content}`]: {
+ paddingRight: 32,
+ },
+ },
+ [`&:has(.${toasterClasses.loader})`]: baseStyles.toastLoader,
+ /*
+ * With loader
+ */
+ [`&:has(.${toasterClasses.loader})`]: baseStyles.toastLoader,
+ [`& .${toasterClasses.loader}`]: loadingStyles,
+ },
+ /*
+ * Error
+ */
+ [`& .${toasterClasses.error}`]: {
+ ...baseStyles.toastColor,
+ [`& .${toasterClasses.icon}`]: {
+ color: theme.vars.palette.error.main,
+ backgroundColor: varAlpha(theme.vars.palette.error.mainChannel, 0.08),
+ },
+ },
+ /*
+ * Success
+ */
+ [`& .${toasterClasses.success}`]: {
+ ...baseStyles.toastColor,
+ [`& .${toasterClasses.icon}`]: {
+ color: theme.vars.palette.success.main,
+ backgroundColor: varAlpha(theme.vars.palette.success.mainChannel, 0.08),
+ },
+ },
+ /*
+ * Warning
+ */
+ [`& .${toasterClasses.warning}`]: {
+ ...baseStyles.toastColor,
+ [`& .${toasterClasses.icon}`]: {
+ color: theme.vars.palette.warning.main,
+ backgroundColor: varAlpha(theme.vars.palette.warning.mainChannel, 0.08),
+ },
+ },
+ /*
+ * Info
+ */
+ [`& .${toasterClasses.info}`]: {
+ ...baseStyles.toastColor,
+ [`& .${toasterClasses.icon}`]: {
+ color: theme.vars.palette.info.main,
+ backgroundColor: varAlpha(theme.vars.palette.info.mainChannel, 0.08),
+ },
+ },
+ };
+});
diff --git a/dashboard/src/components/svg-color/classes.ts b/dashboard/src/components/svg-color/classes.ts
new file mode 100644
index 00000000..2d56bc83
--- /dev/null
+++ b/dashboard/src/components/svg-color/classes.ts
@@ -0,0 +1,3 @@
+// ----------------------------------------------------------------------
+
+export const svgColorClasses = { root: 'mnl__svg__color__root' };
diff --git a/dashboard/src/components/svg-color/index.ts b/dashboard/src/components/svg-color/index.ts
new file mode 100644
index 00000000..372c31dd
--- /dev/null
+++ b/dashboard/src/components/svg-color/index.ts
@@ -0,0 +1,5 @@
+export * from './classes';
+
+export * from './svg-color';
+
+export type * from './types';
diff --git a/dashboard/src/components/svg-color/svg-color.tsx b/dashboard/src/components/svg-color/svg-color.tsx
new file mode 100644
index 00000000..51092fbe
--- /dev/null
+++ b/dashboard/src/components/svg-color/svg-color.tsx
@@ -0,0 +1,28 @@
+import { forwardRef } from 'react';
+
+import Box from '@mui/material/Box';
+
+import { svgColorClasses } from './classes';
+
+import type { SvgColorProps } from './types';
+
+// ----------------------------------------------------------------------
+
+export const SvgColor = forwardRef(({ src, width = 24, className, sx, ...other }, ref) => (
+
+));
diff --git a/dashboard/src/components/svg-color/types.ts b/dashboard/src/components/svg-color/types.ts
new file mode 100644
index 00000000..c795d7b3
--- /dev/null
+++ b/dashboard/src/components/svg-color/types.ts
@@ -0,0 +1,7 @@
+import type { BoxProps } from '@mui/material/Box';
+
+// ----------------------------------------------------------------------
+
+export type SvgColorProps = BoxProps & {
+ src: string;
+};
diff --git a/dashboard/src/components/table/index.ts b/dashboard/src/components/table/index.ts
new file mode 100644
index 00000000..aa4dcd7c
--- /dev/null
+++ b/dashboard/src/components/table/index.ts
@@ -0,0 +1,17 @@
+export * from './utils';
+
+export * from './use-table';
+
+export type * from './types';
+
+export * from './table-no-data';
+
+export * from './table-skeleton';
+
+export * from './table-empty-rows';
+
+export * from './table-head-custom';
+
+export * from './table-selected-action';
+
+export * from './table-pagination-custom';
diff --git a/dashboard/src/components/table/table-empty-rows.tsx b/dashboard/src/components/table/table-empty-rows.tsx
new file mode 100644
index 00000000..62dc9816
--- /dev/null
+++ b/dashboard/src/components/table/table-empty-rows.tsx
@@ -0,0 +1,21 @@
+import TableRow from '@mui/material/TableRow';
+import TableCell from '@mui/material/TableCell';
+
+// ----------------------------------------------------------------------
+
+export type TableEmptyRowsProps = {
+ height?: number;
+ emptyRows: number;
+};
+
+export function TableEmptyRows({ emptyRows, height }: TableEmptyRowsProps) {
+ if (!emptyRows) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/dashboard/src/components/table/table-head-custom.tsx b/dashboard/src/components/table/table-head-custom.tsx
new file mode 100644
index 00000000..5c057d6b
--- /dev/null
+++ b/dashboard/src/components/table/table-head-custom.tsx
@@ -0,0 +1,92 @@
+import type { Theme, SxProps } from '@mui/material/styles';
+
+import Box from '@mui/material/Box';
+import TableRow from '@mui/material/TableRow';
+import Checkbox from '@mui/material/Checkbox';
+import TableHead from '@mui/material/TableHead';
+import TableCell from '@mui/material/TableCell';
+import TableSortLabel from '@mui/material/TableSortLabel';
+
+// ----------------------------------------------------------------------
+
+const visuallyHidden = {
+ border: 0,
+ margin: -1,
+ padding: 0,
+ width: '1px',
+ height: '1px',
+ overflow: 'hidden',
+ position: 'absolute',
+ whiteSpace: 'nowrap',
+ clip: 'rect(0 0 0 0)',
+} as const;
+
+// ----------------------------------------------------------------------
+
+export type TableHeadCustomProps = {
+ orderBy?: string;
+ rowCount?: number;
+ sx?: SxProps;
+ numSelected?: number;
+ order?: 'asc' | 'desc';
+ onSort?: (id: string) => void;
+ headLabel: Record[];
+ onSelectAllRows?: (checked: boolean) => void;
+};
+
+export function TableHeadCustom({
+ sx,
+ order,
+ onSort,
+ orderBy,
+ headLabel,
+ rowCount = 0,
+ numSelected = 0,
+ onSelectAllRows,
+}: TableHeadCustomProps) {
+ return (
+
+
+ {onSelectAllRows && (
+
+ ) => onSelectAllRows(event.target.checked)}
+ inputProps={{
+ name: 'select-all-rows',
+ 'aria-label': 'select all rows',
+ }}
+ />
+
+ )}
+
+ {headLabel.map((headCell) => (
+
+ {onSort ? (
+ onSort(headCell.id)}
+ >
+ {headCell.label}
+
+ {orderBy === headCell.id ? (
+ {order === 'desc' ? 'sorted descending' : 'sorted ascending'}
+ ) : null}
+
+ ) : (
+ headCell.label
+ )}
+
+ ))}
+
+
+ );
+}
diff --git a/dashboard/src/components/table/table-no-data.tsx b/dashboard/src/components/table/table-no-data.tsx
new file mode 100644
index 00000000..6fc8caa1
--- /dev/null
+++ b/dashboard/src/components/table/table-no-data.tsx
@@ -0,0 +1,27 @@
+import type { Theme, SxProps } from '@mui/material/styles';
+
+import TableRow from '@mui/material/TableRow';
+import TableCell from '@mui/material/TableCell';
+
+import { EmptyContent } from '../empty-content';
+
+// ----------------------------------------------------------------------
+
+export type TableNoDataProps = {
+ notFound: boolean;
+ sx?: SxProps;
+};
+
+export function TableNoData({ notFound, sx }: TableNoDataProps) {
+ return (
+
+ {notFound ? (
+
+
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/dashboard/src/components/table/table-pagination-custom.tsx b/dashboard/src/components/table/table-pagination-custom.tsx
new file mode 100644
index 00000000..83c77268
--- /dev/null
+++ b/dashboard/src/components/table/table-pagination-custom.tsx
@@ -0,0 +1,42 @@
+import type { Theme, SxProps } from '@mui/material/styles';
+import type { TablePaginationProps } from '@mui/material/TablePagination';
+
+import Box from '@mui/material/Box';
+import Switch from '@mui/material/Switch';
+import TablePagination from '@mui/material/TablePagination';
+import FormControlLabel from '@mui/material/FormControlLabel';
+
+// ----------------------------------------------------------------------
+
+export type TablePaginationCustomProps = TablePaginationProps & {
+ dense?: boolean;
+ sx?: SxProps;
+ onChangeDense?: (event: React.ChangeEvent) => void;
+};
+
+export function TablePaginationCustom({
+ sx,
+ dense,
+ onChangeDense,
+ rowsPerPageOptions = [5, 10, 25],
+ ...other
+}: TablePaginationCustomProps) {
+ return (
+
+
+
+ {onChangeDense && (
+ }
+ sx={{
+ pl: 2,
+ py: 1.5,
+ top: 0,
+ position: { sm: 'absolute' },
+ }}
+ />
+ )}
+
+ );
+}
diff --git a/dashboard/src/components/table/table-selected-action.tsx b/dashboard/src/components/table/table-selected-action.tsx
new file mode 100644
index 00000000..803311e3
--- /dev/null
+++ b/dashboard/src/components/table/table-selected-action.tsx
@@ -0,0 +1,62 @@
+import type { StackProps } from '@mui/material/Stack';
+
+import Stack from '@mui/material/Stack';
+import Checkbox from '@mui/material/Checkbox';
+import Typography from '@mui/material/Typography';
+
+// ----------------------------------------------------------------------
+
+export type TableSelectedActionProps = StackProps & {
+ dense?: boolean;
+ rowCount: number;
+ numSelected: number;
+ action?: React.ReactNode;
+ onSelectAllRows: (checked: boolean) => void;
+};
+
+export function TableSelectedAction({ dense, action, rowCount, numSelected, onSelectAllRows, sx, ...other }: TableSelectedActionProps) {
+ if (!numSelected) {
+ return null;
+ }
+
+ return (
+
+ ) => onSelectAllRows(event.target.checked)}
+ />
+
+
+ {numSelected} selected
+
+
+ {action && action}
+
+ );
+}
diff --git a/dashboard/src/components/table/table-skeleton.tsx b/dashboard/src/components/table/table-skeleton.tsx
new file mode 100644
index 00000000..f6532f12
--- /dev/null
+++ b/dashboard/src/components/table/table-skeleton.tsx
@@ -0,0 +1,32 @@
+import type { TableRowProps } from '@mui/material/TableRow';
+
+import Stack from '@mui/material/Stack';
+import Skeleton from '@mui/material/Skeleton';
+import TableRow from '@mui/material/TableRow';
+import TableCell from '@mui/material/TableCell';
+
+// ----------------------------------------------------------------------
+
+export function TableSkeleton({ ...other }: TableRowProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/dashboard/src/components/table/types.ts b/dashboard/src/components/table/types.ts
new file mode 100644
index 00000000..550e72e4
--- /dev/null
+++ b/dashboard/src/components/table/types.ts
@@ -0,0 +1,28 @@
+// ----------------------------------------------------------------------
+
+export type TableProps = {
+ dense: boolean;
+ page: number;
+ rowsPerPage: number;
+ order: 'asc' | 'desc';
+ orderBy: string;
+ //
+ selected: string[];
+ onSelectRow: (id: string) => void;
+ onSelectAllRows: (checked: boolean, newSelecteds: string[]) => void;
+ //
+ onResetPage: () => void;
+ onSort: (id: string) => void;
+ onChangePage: (event: unknown, newPage: number) => void;
+ onChangeRowsPerPage: (event: React.ChangeEvent) => void;
+ onChangeDense: (event: React.ChangeEvent) => void;
+ onUpdatePageDeleteRow: (totalRowsInPage: number) => void;
+ onUpdatePageDeleteRows: ({ totalRowsInPage, totalRowsFiltered }: { totalRowsInPage: number; totalRowsFiltered: number }) => void;
+ //
+ setPage: React.Dispatch>;
+ setDense: React.Dispatch>;
+ setOrder: React.Dispatch>;
+ setOrderBy: React.Dispatch>;
+ setSelected: React.Dispatch>;
+ setRowsPerPage: React.Dispatch>;
+};
diff --git a/dashboard/src/components/table/use-table.ts b/dashboard/src/components/table/use-table.ts
new file mode 100644
index 00000000..fb39ad34
--- /dev/null
+++ b/dashboard/src/components/table/use-table.ts
@@ -0,0 +1,135 @@
+import { useState, useCallback } from 'react';
+
+import type { TableProps } from './types';
+
+// ----------------------------------------------------------------------
+
+type UseTableReturn = TableProps;
+
+export type UseTableProps = {
+ defaultDense?: boolean;
+ defaultOrder?: 'asc' | 'desc';
+ defaultOrderBy?: string;
+ defaultSelected?: string[];
+ defaultRowsPerPage?: number;
+ defaultCurrentPage?: number;
+};
+
+export function useTable(props?: UseTableProps): UseTableReturn {
+ const [dense, setDense] = useState(!!props?.defaultDense);
+
+ const [page, setPage] = useState(props?.defaultCurrentPage || 0);
+
+ const [orderBy, setOrderBy] = useState(props?.defaultOrderBy || 'name');
+
+ const [rowsPerPage, setRowsPerPage] = useState(props?.defaultRowsPerPage || 10);
+
+ const [order, setOrder] = useState<'asc' | 'desc'>(props?.defaultOrder || 'desc');
+
+ const [selected, setSelected] = useState(props?.defaultSelected || []);
+
+ const onSort = useCallback(
+ (id: string) => {
+ const isAsc = orderBy === id && order === 'asc';
+ if (id !== '') {
+ setOrder(isAsc ? 'desc' : 'asc');
+ setOrderBy(id);
+ }
+ },
+ [order, orderBy]
+ );
+
+ const onSelectRow = useCallback(
+ (inputValue: string) => {
+ const newSelected = selected.includes(inputValue) ? selected.filter((value) => value !== inputValue) : [...selected, inputValue];
+
+ setSelected(newSelected);
+ },
+ [selected]
+ );
+
+ const onChangeRowsPerPage = useCallback((event: React.ChangeEvent) => {
+ setPage(0);
+ setRowsPerPage(parseInt(event.target.value, 10));
+ }, []);
+
+ const onChangeDense = useCallback((event: React.ChangeEvent) => {
+ setDense(event.target.checked);
+ }, []);
+
+ const onSelectAllRows = useCallback((checked: boolean, inputValue: string[]) => {
+ if (checked) {
+ setSelected(inputValue);
+ return;
+ }
+ setSelected([]);
+ }, []);
+
+ const onChangePage = useCallback((event: unknown, newPage: number) => {
+ setPage(newPage);
+ }, []);
+
+ const onResetPage = useCallback(() => {
+ setPage(0);
+ }, []);
+
+ const onUpdatePageDeleteRow = useCallback(
+ (totalRowsInPage: number) => {
+ setSelected([]);
+ if (page) {
+ if (totalRowsInPage < 2) {
+ setPage(page - 1);
+ }
+ }
+ },
+ [page]
+ );
+
+ const onUpdatePageDeleteRows = useCallback(
+ ({ totalRowsInPage, totalRowsFiltered }: { totalRowsInPage: number; totalRowsFiltered: number }) => {
+ const totalSelected = selected.length;
+
+ setSelected([]);
+
+ if (page) {
+ if (totalSelected === totalRowsInPage) {
+ setPage(page - 1);
+ } else if (totalSelected === totalRowsFiltered) {
+ setPage(0);
+ } else if (totalSelected > totalRowsInPage) {
+ const newPage = Math.ceil((totalRowsFiltered - totalSelected) / rowsPerPage) - 1;
+
+ setPage(newPage);
+ }
+ }
+ },
+ [page, rowsPerPage, selected.length]
+ );
+
+ return {
+ dense,
+ order,
+ page,
+ orderBy,
+ rowsPerPage,
+ //
+ selected,
+ onSelectRow,
+ onSelectAllRows,
+ //
+ onSort,
+ onChangePage,
+ onChangeDense,
+ onResetPage,
+ onChangeRowsPerPage,
+ onUpdatePageDeleteRow,
+ onUpdatePageDeleteRows,
+ //
+ setPage,
+ setDense,
+ setOrder,
+ setOrderBy,
+ setSelected,
+ setRowsPerPage,
+ };
+}
diff --git a/dashboard/src/components/table/utils.ts b/dashboard/src/components/table/utils.ts
new file mode 100644
index 00000000..d9382971
--- /dev/null
+++ b/dashboard/src/components/table/utils.ts
@@ -0,0 +1,51 @@
+// ----------------------------------------------------------------------
+
+export function rowInPage(data: T[], page: number, rowsPerPage: number) {
+ return data.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage);
+}
+
+// ----------------------------------------------------------------------
+
+export function emptyRows(page: number, rowsPerPage: number, arrayLength: number) {
+ return page ? Math.max(0, (1 + page) * rowsPerPage - arrayLength) : 0;
+}
+
+// ----------------------------------------------------------------------
+
+function descendingComparator(a: T, b: T, orderBy: keyof T) {
+ if (a[orderBy] === undefined) {
+ return 1;
+ }
+ if (b[orderBy] === undefined) {
+ return -1;
+ }
+ if (a[orderBy] === null) {
+ return 1;
+ }
+ if (b[orderBy] === null) {
+ return -1;
+ }
+ if (b[orderBy] < a[orderBy]) {
+ return -1;
+ }
+ if (b[orderBy] > a[orderBy]) {
+ return 1;
+ }
+ return 0;
+}
+
+// ----------------------------------------------------------------------
+
+export function getComparator(
+ order: 'asc' | 'desc',
+ orderBy: Key
+): (
+ a: {
+ [key in Key]: number | string | Date;
+ },
+ b: {
+ [key in Key]: number | string | Date;
+ }
+) => number {
+ return order === 'desc' ? (a, b) => descendingComparator(a, b, orderBy) : (a, b) => -descendingComparator(a, b, orderBy);
+}
diff --git a/dashboard/src/components/transactions/clean-transactions-button.tsx b/dashboard/src/components/transactions/clean-transactions-button.tsx
new file mode 100644
index 00000000..eaa4f747
--- /dev/null
+++ b/dashboard/src/components/transactions/clean-transactions-button.tsx
@@ -0,0 +1,65 @@
+import type { LoadingButtonProps } from '@mui/lab';
+
+import { LoadingButton } from '@mui/lab';
+
+import { useBoolean } from 'src/hooks/use-boolean';
+
+import { useTranslate } from 'src/locales';
+import { deleteFailedPayments, deleteExpiredInvoices } from 'src/lib/swissknife';
+
+import { toast } from 'src/components/snackbar';
+
+import { TransactionType } from 'src/types/transaction';
+
+// ----------------------------------------------------------------------
+
+interface Props {
+ onSuccess: VoidFunction;
+ buttonProps?: LoadingButtonProps;
+ children?: React.ReactNode;
+ transactionType?: TransactionType;
+}
+
+export function CleanTransactionsButton({ onSuccess, buttonProps, transactionType, children }: Props) {
+ const { t } = useTranslate();
+ const isDeleting = useBoolean();
+
+ const handleCleanTransactions = async () => {
+ try {
+ isDeleting.onTrue();
+
+ let nInvoicesDeleted = 0;
+ let nPaymentsDeleted = 0;
+
+ if (transactionType === TransactionType.INVOICE || !transactionType) {
+ const { data } = await deleteExpiredInvoices();
+ nInvoicesDeleted = data!;
+ }
+
+ if (transactionType === TransactionType.PAYMENT || !transactionType) {
+ const { data } = await deleteFailedPayments();
+ nPaymentsDeleted = data!;
+ }
+
+ if (nInvoicesDeleted > 0) {
+ toast.success(t('clean_transactions_button.invoices_deleted_success', { count: nInvoicesDeleted }));
+ onSuccess();
+ }
+
+ if (nPaymentsDeleted > 0) {
+ toast.success(t('clean_transactions_button.payments_deleted_success', { count: nPaymentsDeleted }));
+ onSuccess();
+ }
+ } catch (error) {
+ toast.error(error.reason);
+ } finally {
+ isDeleting.onFalse();
+ }
+ };
+
+ return (
+
+ {children || t('clean_transactions_button.clean_transactions')}{' '}
+
+ );
+}
diff --git a/dashboard/src/components/transactions/confirm-payment-dialog.tsx b/dashboard/src/components/transactions/confirm-payment-dialog.tsx
new file mode 100644
index 00000000..0815f8e9
--- /dev/null
+++ b/dashboard/src/components/transactions/confirm-payment-dialog.tsx
@@ -0,0 +1,339 @@
+import type { IFiatPrices } from 'src/types/bitcoin';
+import type { InputProps } from '@mui/material/Input';
+import type { DialogProps } from '@mui/material/Dialog';
+import type { PaymentResponse, SendPaymentRequest } from 'src/lib/swissknife';
+
+import { m } from 'framer-motion';
+import { useForm } from 'react-hook-form';
+import { ajvResolver } from '@hookform/resolvers/ajv';
+import { useState, useEffect, useCallback } from 'react';
+
+import Box from '@mui/material/Box';
+import { Link } from '@mui/material';
+import Stack from '@mui/material/Stack';
+import { LoadingButton } from '@mui/lab';
+import Button from '@mui/material/Button';
+import Avatar from '@mui/material/Avatar';
+import Dialog from '@mui/material/Dialog';
+import Tooltip from '@mui/material/Tooltip';
+import Typography from '@mui/material/Typography';
+import DialogTitle from '@mui/material/DialogTitle';
+import ListItemText from '@mui/material/ListItemText';
+import DialogActions from '@mui/material/DialogActions';
+import Input, { inputClasses } from '@mui/material/Input';
+
+import { ajvOptions } from 'src/utils/ajv';
+import { satsToFiat } from 'src/utils/fiat';
+import { fCurrency } from 'src/utils/format-number';
+import { truncateText } from 'src/utils/format-string';
+
+import { maxLine } from 'src/theme/styles';
+import { CONFIG } from 'src/config-global';
+import { useTranslate } from 'src/locales';
+import { pay, walletPay, SendPaymentRequestSchema } from 'src/lib/swissknife';
+
+import { toast } from 'src/components/snackbar';
+import { SatsWithIcon } from 'src/components/bitcoin';
+import { Form } from 'src/components/hook-form/form-provider';
+import { varBounce, MotionContainer } from 'src/components/animate';
+import { RHFTextField, RHFWalletSelect } from 'src/components/hook-form';
+
+import { useSettingsContext } from '../settings';
+
+// ----------------------------------------------------------------------
+
+const MIN_AMOUNT = 0;
+const MAX_AMOUNT = 200000;
+
+// ----------------------------------------------------------------------
+
+type ConfirmPaymentDialogProps = DialogProps & {
+ input: string;
+ fiatPrices: IFiatPrices;
+ bolt11?: any;
+ onClose: () => void;
+ onSuccess?: () => void;
+ isAdmin?: boolean;
+ walletId?: string;
+};
+
+// @ts-ignore
+const resolver = ajvResolver(SendPaymentRequestSchema, ajvOptions);
+
+export function ConfirmPaymentDialog({
+ open,
+ input,
+ isAdmin,
+ walletId,
+ fiatPrices,
+ bolt11,
+ onClose,
+ onSuccess,
+}: ConfirmPaymentDialogProps) {
+ const { t } = useTranslate();
+
+ const [autoWidth, setAutoWidth] = useState(24);
+ const [payment, setPayment] = useState(undefined);
+ const { currency } = useSettingsContext();
+
+ const methods = useForm({
+ resolver,
+ defaultValues: {
+ amount_msat: MIN_AMOUNT,
+ comment: '',
+ wallet: null,
+ input,
+ },
+ });
+
+ const {
+ watch,
+ handleSubmit,
+ setValue,
+ formState: { isSubmitting },
+ reset,
+ } = methods;
+
+ const amount = watch('amount_msat');
+ const wallet = watch('wallet');
+
+ const onSubmit = async (body: any) => {
+ try {
+ let paymentResponse;
+ const reqBody: SendPaymentRequest = {
+ wallet_id: walletId || body.wallet?.id,
+ amount_msat: body.amount_msat! * 1000,
+ comment: body.comment || undefined,
+ input: body.input,
+ };
+
+ if (isAdmin) {
+ const { data } = await pay({ body: reqBody });
+ paymentResponse = data;
+ } else {
+ const { data } = await walletPay({ body: reqBody });
+ paymentResponse = data;
+ }
+
+ reset();
+ setPayment(paymentResponse);
+ onSuccess?.();
+ } catch (error) {
+ toast.error(error.reason);
+ }
+ };
+
+ useEffect(() => {
+ if (bolt11) {
+ const amountSection = bolt11.sections.find((s: any) => s.name === 'amount');
+ const satsAmount = amountSection ? amountSection.value / 1000 : MIN_AMOUNT;
+ const comment = bolt11.description || '';
+
+ reset({
+ amount_msat: satsAmount,
+ comment,
+ wallet: null,
+ input,
+ });
+ } else {
+ reset({
+ amount_msat: MIN_AMOUNT,
+ comment: '',
+ wallet: null,
+ input,
+ });
+ }
+ }, [input, bolt11, reset]);
+
+ const handleAutoWidth = useCallback(() => {
+ const getNumberLength = amount.toString().length;
+ setAutoWidth(getNumberLength * 24);
+ }, [amount]);
+
+ useEffect(() => {
+ handleAutoWidth();
+ }, [handleAutoWidth, amount]);
+
+ const handleBlur = useCallback(() => {
+ if (amount !== undefined) {
+ if (amount < 0) {
+ setValue('amount_msat', 0);
+ } else if (amount > MAX_AMOUNT) {
+ setValue('amount_msat', MAX_AMOUNT);
+ }
+ }
+ }, [amount, setValue]);
+
+ const handleChangeAmount = useCallback(
+ (event: React.ChangeEvent) => {
+ setValue('amount_msat', Number(event.target.value));
+ },
+ [setValue]
+ );
+
+ const handleClose = () => {
+ reset();
+ setPayment(undefined);
+ onClose();
+ };
+
+ const invoiceType = () => {
+ if (bolt11) {
+ return t('confirm_payment_dialog.bolt11_transfer');
+ }
+ if (input.includes(CONFIG.site.domain)) {
+ return t('confirm_payment_dialog.internal_transfer');
+ }
+ if (input.toLowerCase().startsWith('lnurl')) {
+ return t('confirm_payment_dialog.lnurl_transfer');
+ }
+ return t('confirm_payment_dialog.lightning_transfer');
+ };
+
+ return (
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+type InputAmountProps = InputProps & {
+ autoWidth: number;
+ amount: number | number[];
+};
+
+function InputAmount({ autoWidth, amount, disabled, onBlur, onChange, sx, ...other }: InputAmountProps) {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/dashboard/src/components/transactions/index.ts b/dashboard/src/components/transactions/index.ts
new file mode 100644
index 00000000..34532323
--- /dev/null
+++ b/dashboard/src/components/transactions/index.ts
@@ -0,0 +1,10 @@
+export * from './new-invoice-form';
+export * from './new-invoice-card';
+export * from './new-payment-form';
+
+export * from './new-payment-card';
+export * from './new-invoice-dialog';
+export * from './new-payment-dialog';
+export * from './confirm-payment-dialog';
+
+export * from './clean-transactions-button';
diff --git a/dashboard/src/components/transactions/new-invoice-card.tsx b/dashboard/src/components/transactions/new-invoice-card.tsx
new file mode 100644
index 00000000..8f88f60a
--- /dev/null
+++ b/dashboard/src/components/transactions/new-invoice-card.tsx
@@ -0,0 +1,27 @@
+import type { CardProps } from '@mui/material';
+
+import Box from '@mui/material/Box';
+import { Card, CardHeader } from '@mui/material';
+
+import { NewInvoiceForm } from './new-invoice-form';
+
+import type { NewInvoiceFormProps } from './new-invoice-form';
+
+// ----------------------------------------------------------------------
+
+type Props = CardProps &
+ NewInvoiceFormProps & {
+ subheader?: string;
+ };
+
+export function NewInvoiceCard({ onSuccess, title, subheader, lnAddress, fiatPrices, sx, ...other }: Props) {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/dashboard/src/components/transactions/new-invoice-dialog.tsx b/dashboard/src/components/transactions/new-invoice-dialog.tsx
new file mode 100644
index 00000000..f706b759
--- /dev/null
+++ b/dashboard/src/components/transactions/new-invoice-dialog.tsx
@@ -0,0 +1,38 @@
+import type { DialogProps } from '@mui/material/Dialog';
+
+import Box from '@mui/material/Box';
+import Button from '@mui/material/Button';
+import Dialog from '@mui/material/Dialog';
+import DialogTitle from '@mui/material/DialogTitle';
+import DialogActions from '@mui/material/DialogActions';
+
+import { useTranslate } from 'src/locales';
+
+import { NewInvoiceForm } from './new-invoice-form';
+
+import type { NewInvoiceFormProps } from './new-invoice-form';
+
+// ----------------------------------------------------------------------
+
+type Props = DialogProps &
+ NewInvoiceFormProps & {
+ onClose: VoidFunction;
+ };
+
+export function NewInvoiceDialog({ title, open, onClose, ...other }: Props) {
+ const { t } = useTranslate();
+
+ return (
+
+ );
+}
diff --git a/dashboard/src/components/transactions/new-invoice-form.tsx b/dashboard/src/components/transactions/new-invoice-form.tsx
new file mode 100644
index 00000000..c8003b72
--- /dev/null
+++ b/dashboard/src/components/transactions/new-invoice-form.tsx
@@ -0,0 +1,243 @@
+import type { IFiatPrices } from 'src/types/bitcoin';
+import type { InputProps } from '@mui/material/Input';
+import type { LnAddress, NewInvoiceRequest } from 'src/lib/swissknife';
+
+import { useForm } from 'react-hook-form';
+import { ajvResolver } from '@hookform/resolvers/ajv';
+import { useState, useEffect, useCallback } from 'react';
+
+import Box from '@mui/material/Box';
+import Stack from '@mui/material/Stack';
+import { LoadingButton } from '@mui/lab';
+import Button from '@mui/material/Button';
+import Typography from '@mui/material/Typography';
+import Input, { inputClasses } from '@mui/material/Input';
+
+import { useBoolean } from 'src/hooks/use-boolean';
+
+import { ajvOptions } from 'src/utils/ajv';
+import { satsToFiat } from 'src/utils/fiat';
+import { displayLnAddress } from 'src/utils/lnurl';
+import { fCurrency } from 'src/utils/format-number';
+
+import { useTranslate } from 'src/locales';
+import { generateInvoice, newWalletInvoice, NewInvoiceRequestSchema } from 'src/lib/swissknife';
+
+import { toast } from 'src/components/snackbar';
+import { Iconify } from 'src/components/iconify';
+import { Form } from 'src/components/hook-form/form-provider';
+import { RHFSlider, RHFTextField, RHFWalletSelect } from 'src/components/hook-form';
+
+import { QRDialog } from '../qr';
+import { useSettingsContext } from '../settings';
+
+// ----------------------------------------------------------------------
+
+const MIN_AMOUNT = 0;
+const MAX_AMOUNT = 200000;
+
+// ----------------------------------------------------------------------
+
+export type NewInvoiceFormProps = {
+ lnAddress?: LnAddress | null;
+ fiatPrices: IFiatPrices;
+ onSuccess?: VoidFunction;
+ isAdmin?: boolean;
+ walletId?: string;
+};
+
+// @ts-ignore
+const resolver = ajvResolver(NewInvoiceRequestSchema, ajvOptions);
+
+export function NewInvoiceForm({ fiatPrices, isAdmin, walletId, lnAddress, onSuccess }: NewInvoiceFormProps) {
+ const { t } = useTranslate();
+ const [autoWidth, setAutoWidth] = useState(24);
+ const [qrValue, setQrValue] = useState('');
+ const confirm = useBoolean();
+ const { currency } = useSettingsContext();
+
+ const methods = useForm({
+ resolver,
+ defaultValues: {
+ amount_msat: MIN_AMOUNT,
+ description: '',
+ wallet: null,
+ },
+ });
+
+ const {
+ watch,
+ setValue,
+ handleSubmit,
+ reset,
+ formState: { isSubmitting },
+ } = methods;
+
+ const amount = watch('amount_msat');
+ const wallet = watch('wallet');
+
+ const handleAutoWidth = useCallback(() => {
+ const getNumberLength = amount.toString().length;
+ setAutoWidth(getNumberLength * 24);
+ }, [amount]);
+
+ useEffect(() => {
+ handleAutoWidth();
+ }, [amount, handleAutoWidth]);
+
+ const handleChangeSlider = useCallback(
+ (_: Event, newValue: number | number[]) => {
+ setValue('amount_msat', newValue as number);
+ },
+ [setValue]
+ );
+
+ const handleChangeAmount = useCallback(
+ (event: React.ChangeEvent) => {
+ setValue('amount_msat', Number(event.target.value));
+ },
+ [setValue]
+ );
+
+ const handleBlur = useCallback(() => {
+ if (amount < 0) {
+ setValue('amount_msat', 0);
+ } else if (amount > MAX_AMOUNT) {
+ setValue('amount_msat', MAX_AMOUNT);
+ }
+ }, [amount, setValue]);
+
+ const onSubmit = async (body: any) => {
+ try {
+ let invoice;
+ const reqBody: NewInvoiceRequest = {
+ amount_msat: body.amount_msat * 1000,
+ description: body.description || undefined,
+ wallet_id: walletId || body.wallet?.id,
+ };
+
+ if (isAdmin) {
+ const { data } = await generateInvoice({ body: reqBody });
+ invoice = data!;
+ } else {
+ const { data } = await newWalletInvoice({ body: reqBody });
+ invoice = data!;
+ }
+
+ setQrValue(invoice.ln_invoice!.bolt11);
+ confirm.onTrue();
+ reset();
+ onSuccess?.();
+ } catch (error) {
+ toast.error(error.reason);
+ }
+ };
+
+ return (
+ <>
+
+
+ >
+ );
+}
+
+// ----------------------------------------------------------------------
+
+type InputAmountProps = InputProps & {
+ autoWidth: number;
+ amount: number | number[];
+};
+
+function InputAmount({ autoWidth, amount, onBlur, onChange, sx, ...other }: InputAmountProps) {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/dashboard/src/components/transactions/new-payment-card.tsx b/dashboard/src/components/transactions/new-payment-card.tsx
new file mode 100644
index 00000000..e6e31d44
--- /dev/null
+++ b/dashboard/src/components/transactions/new-payment-card.tsx
@@ -0,0 +1,30 @@
+import type { CardProps } from '@mui/material';
+import type { IFiatPrices } from 'src/types/bitcoin';
+
+import Box from '@mui/material/Box';
+import { Card, CardHeader } from '@mui/material';
+
+import { NewPaymentForm } from './new-payment-form';
+
+import type { NewPaymentFormProps } from './new-payment-form';
+
+// ----------------------------------------------------------------------
+
+interface Props extends CardProps, NewPaymentFormProps {
+ title?: string;
+ subheader?: string;
+ fiatPrices: IFiatPrices;
+ onSuccess: VoidFunction;
+}
+
+export function NewPaymentCard({ title, subheader, sx, fiatPrices, onSuccess, ...other }: Props) {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/dashboard/src/components/transactions/new-payment-dialog.tsx b/dashboard/src/components/transactions/new-payment-dialog.tsx
new file mode 100644
index 00000000..7efae329
--- /dev/null
+++ b/dashboard/src/components/transactions/new-payment-dialog.tsx
@@ -0,0 +1,38 @@
+import type { DialogProps } from '@mui/material/Dialog';
+
+import Box from '@mui/material/Box';
+import Button from '@mui/material/Button';
+import Dialog from '@mui/material/Dialog';
+import DialogTitle from '@mui/material/DialogTitle';
+import DialogActions from '@mui/material/DialogActions';
+
+import { useTranslate } from 'src/locales';
+
+import { NewPaymentForm } from './new-payment-form';
+
+import type { NewPaymentFormProps } from './new-payment-form';
+
+// ----------------------------------------------------------------------
+
+type Props = DialogProps &
+ NewPaymentFormProps & {
+ onClose: VoidFunction;
+ };
+
+export function NewPaymentDialog({ title, open, onClose, ...other }: Props) {
+ const { t } = useTranslate();
+
+ return (
+
+ );
+}
diff --git a/dashboard/src/components/transactions/new-payment-form.tsx b/dashboard/src/components/transactions/new-payment-form.tsx
new file mode 100644
index 00000000..de1808e9
--- /dev/null
+++ b/dashboard/src/components/transactions/new-payment-form.tsx
@@ -0,0 +1,243 @@
+import type { Contact } from 'src/lib/swissknife';
+import type { IFiatPrices } from 'src/types/bitcoin';
+import type { DialogProps } from '@mui/material/Dialog';
+import type { IDetectedBarcode } from '@yudiel/react-qr-scanner';
+
+import { decode } from 'light-bolt11-decoder';
+import { useState, useCallback } from 'react';
+import { Scanner } from '@yudiel/react-qr-scanner';
+
+import Box from '@mui/material/Box';
+import Stack from '@mui/material/Stack';
+import Button from '@mui/material/Button';
+import Avatar from '@mui/material/Avatar';
+import Dialog from '@mui/material/Dialog';
+import Tooltip from '@mui/material/Tooltip';
+import { useTheme } from '@mui/material/styles';
+import TextField from '@mui/material/TextField';
+import Typography from '@mui/material/Typography';
+import DialogActions from '@mui/material/DialogActions';
+
+import { paths } from 'src/routes/paths';
+
+import { useBoolean } from 'src/hooks/use-boolean';
+
+import { useTranslate } from 'src/locales';
+import { varAlpha, stylesMode } from 'src/theme/styles';
+
+import { Iconify } from 'src/components/iconify';
+import { SatsWithIcon } from 'src/components/bitcoin';
+import { Carousel, useCarousel, CarouselArrowFloatButtons } from 'src/components/carousel';
+
+import { ConfirmPaymentDialog } from './confirm-payment-dialog';
+
+// ----------------------------------------------------------------------
+
+export type NewPaymentFormProps = {
+ contacts?: Contact[];
+ balance?: number;
+ fiatPrices: IFiatPrices;
+ onSuccess?: () => void;
+ isAdmin?: boolean;
+ walletId?: string;
+};
+
+export function NewPaymentForm({ balance, fiatPrices, isAdmin, walletId, contacts, onSuccess }: NewPaymentFormProps) {
+ const { t } = useTranslate();
+ const theme = useTheme();
+ const [input, setInput] = useState('');
+ const [bolt11, setBolt11] = useState(undefined);
+ const confirm = useBoolean();
+ const scanQR = useBoolean();
+
+ const carousel = useCarousel({
+ loop: true,
+ dragFree: true,
+ slidesToShow: 'auto',
+ slideSpacing: '20px',
+ });
+
+ const handleChangeInput = useCallback((event: React.ChangeEvent) => {
+ setInput(event.target.value);
+ }, []);
+
+ const handleConfirm = useCallback(
+ (event: any) => {
+ event.preventDefault();
+ try {
+ const decodedBolt11 = decode(input);
+ setBolt11(decodedBolt11);
+ } catch (_) {
+ setBolt11(undefined);
+ }
+ confirm.onTrue();
+ },
+ [input, setBolt11, confirm]
+ );
+
+ const handlerClickDot = useCallback(
+ (index: number) => {
+ if (contacts === undefined) return;
+
+ carousel.dots.onClickDot(index);
+ setInput(contacts[index].ln_address);
+ },
+ [contacts, carousel.dots]
+ );
+
+ const handleClose = () => {
+ setInput('');
+ setBolt11(undefined);
+ confirm.onFalse();
+ };
+
+ return (
+ <>
+ {contacts && contacts.length > 0 && (
+ <>
+
+
+ {t('new_payment.recent')}
+
+
+ }
+ sx={{ mr: -1 }}
+ >
+ {t('view_all')}
+
+
+
+
+
+
+
+ {contacts.map((contact, index) => (
+
+ handlerClickDot(index)}
+ sx={{
+ mx: 'auto',
+ opacity: 0.48,
+ cursor: 'pointer',
+ transition: theme.transitions.create('all'),
+ ...(index === carousel.dots.selectedIndex && {
+ opacity: 1,
+ transform: 'scale(1.25)',
+ boxShadow: `-4px 12px 24px 0 ${varAlpha(theme.vars.palette.common.blackChannel, 0.12)}`,
+ [stylesMode.dark]: {
+ boxShadow: `-4px 12px 24px 0 ${varAlpha(theme.vars.palette.common.blackChannel, 0.24)}`,
+ },
+ }),
+ }}
+ >
+ {contact.ln_address?.charAt(0).toUpperCase()}
+
+
+ ))}
+
+
+ >
+ )}
+
+
+
+
+ {balance != null && (
+
+
+ {t('new_payment.your_balance')}{' '}
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+// ----------------------------------------------------------------------
+
+type ScanQRDialogProps = DialogProps & {
+ onClose: () => void;
+ onResult: (result: string) => void;
+};
+
+function ScanQRDialog({ open, onClose, onResult }: ScanQRDialogProps) {
+ const { t } = useTranslate();
+
+ const handleScannerResult = (detectedCodes: IDetectedBarcode[]) => {
+ const text = detectedCodes[0].rawValue;
+ onResult(text);
+ onClose();
+ };
+
+ return (
+
+ );
+}
diff --git a/dashboard/src/components/wallet/index.ts b/dashboard/src/components/wallet/index.ts
new file mode 100644
index 00000000..b0b21524
--- /dev/null
+++ b/dashboard/src/components/wallet/index.ts
@@ -0,0 +1,2 @@
+export * from './register-wallet-form';
+export * from './register-wallet-dialog';
diff --git a/dashboard/src/components/wallet/register-wallet-dialog.tsx b/dashboard/src/components/wallet/register-wallet-dialog.tsx
new file mode 100644
index 00000000..c599774d
--- /dev/null
+++ b/dashboard/src/components/wallet/register-wallet-dialog.tsx
@@ -0,0 +1,38 @@
+import type { DialogProps } from '@mui/material/Dialog';
+
+import Box from '@mui/material/Box';
+import Button from '@mui/material/Button';
+import Dialog from '@mui/material/Dialog';
+import DialogTitle from '@mui/material/DialogTitle';
+import DialogActions from '@mui/material/DialogActions';
+
+import { useTranslate } from 'src/locales';
+
+import { RegisterWalletForm } from './register-wallet-form';
+
+import type { NewWalletFormProps } from './register-wallet-form';
+
+// ----------------------------------------------------------------------
+
+type Props = DialogProps &
+ NewWalletFormProps & {
+ onClose: VoidFunction;
+ };
+
+export function RegisterWalletDialog({ title, open, onClose, onSuccess }: Props) {
+ const { t } = useTranslate();
+
+ return (
+
+ );
+}
diff --git a/dashboard/src/components/wallet/register-wallet-form.tsx b/dashboard/src/components/wallet/register-wallet-form.tsx
new file mode 100644
index 00000000..aa2dff57
--- /dev/null
+++ b/dashboard/src/components/wallet/register-wallet-form.tsx
@@ -0,0 +1,74 @@
+import type { RegisterWalletRequest } from 'src/lib/swissknife';
+
+import { useForm } from 'react-hook-form';
+import { ajvResolver } from '@hookform/resolvers/ajv';
+
+import { Stack } from '@mui/material';
+import { LoadingButton } from '@mui/lab';
+
+import { ajvOptions } from 'src/utils/ajv';
+
+import { useTranslate } from 'src/locales';
+import { registerWallet, RegisterWalletRequestSchema } from 'src/lib/swissknife';
+
+import { toast } from 'src/components/snackbar';
+import { Form, RHFTextField } from 'src/components/hook-form';
+
+// ----------------------------------------------------------------------
+
+export type NewWalletFormProps = {
+ onSuccess: VoidFunction;
+};
+
+// @ts-ignore
+const resolver = ajvResolver(RegisterWalletRequestSchema, ajvOptions);
+
+export function RegisterWalletForm({ onSuccess }: NewWalletFormProps) {
+ const { t } = useTranslate();
+
+ const methods = useForm({
+ resolver,
+ defaultValues: {
+ user_id: '',
+ },
+ });
+
+ const {
+ reset,
+ handleSubmit,
+ formState: { isSubmitting },
+ watch,
+ } = methods;
+
+ const user = watch('user_id');
+
+ const onSubmit = async (body: RegisterWalletRequest) => {
+ try {
+ await registerWallet({ body });
+ toast.success(t('register_wallet.success_wallet_registration'));
+ reset();
+ onSuccess();
+ } catch (error) {
+ toast.error(error.reason);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/dashboard/src/config-global.ts b/dashboard/src/config-global.ts
new file mode 100644
index 00000000..069c8f8f
--- /dev/null
+++ b/dashboard/src/config-global.ts
@@ -0,0 +1,73 @@
+import { paths } from 'src/routes/paths';
+
+import packageJson from '../package.json';
+import { client } from './lib/swissknife';
+
+// ----------------------------------------------------------------------
+
+export type ConfigValue = {
+ isStaticExport: boolean;
+ site: {
+ name: string;
+ serverUrl: string;
+ assetURL: string;
+ basePath: string;
+ version: string;
+ domain: string;
+ mempoolSpace: string;
+ };
+ auth: {
+ method: 'jwt' | 'supabase' | 'auth0';
+ skip: boolean;
+ redirectPath: string;
+ };
+ auth0: { clientId: string; domain: string; callbackUrl: string; audience: string };
+ supabase: { url: string; key: string };
+};
+
+export type AuthMethod = 'jwt' | 'supabase' | 'auth0';
+
+// ----------------------------------------------------------------------
+
+export const CONFIG: ConfigValue = {
+ site: {
+ name: process.env.NEXT_PUBLIC_SITENAME ?? 'Numeraire SwissKnife',
+ serverUrl: process.env.NEXT_PUBLIC_SERVER_URL ?? '',
+ assetURL: process.env.NEXT_PUBLIC_ASSET_URL ?? '',
+ basePath: process.env.NEXT_PUBLIC_BASE_PATH ?? '',
+ domain: process.env.NEXT_PUBLIC_DOMAIN ?? 'numeraire.tech',
+ mempoolSpace: process.env.NEXT_PUBLIC_MEMPOOL_SPACE_URL ?? 'https://mempool.space/api/v1',
+ version: packageJson.version,
+ },
+ isStaticExport: JSON.parse(`${process.env.BUILD_STATIC_EXPORT}`),
+ /**
+ * Auth
+ * @method {AuthMethod}
+ */
+ auth: {
+ method: (process.env.NEXT_PUBLIC_AUTH_METHOD as AuthMethod) ?? 'jwt',
+ skip: false,
+ redirectPath: paths.wallet.root,
+ },
+ /**
+ * Auth0
+ */
+ auth0: {
+ clientId: process.env.NEXT_PUBLIC_AUTH0_CLIENT_ID ?? '',
+ domain: process.env.NEXT_PUBLIC_AUTH0_DOMAIN ?? '',
+ callbackUrl: process.env.NEXT_PUBLIC_AUTH0_CALLBACK_URL ?? '',
+ audience: process.env.NEXT_PUBLIC_AUTH0_AUDIENCE ?? 'https://swissknife.numeraire.tech/api/v1',
+ },
+ /**
+ * Supabase
+ */
+ supabase: {
+ url: process.env.NEXT_PUBLIC_SUPABASE_URL ?? '',
+ key: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? '',
+ },
+};
+
+client.setConfig({
+ baseUrl: CONFIG.site.serverUrl,
+ throwOnError: true,
+});
diff --git a/dashboard/src/global.css b/dashboard/src/global.css
new file mode 100644
index 00000000..d4dbfb11
--- /dev/null
+++ b/dashboard/src/global.css
@@ -0,0 +1,64 @@
+/** **************************************
+* Fonts: app
+*************************************** */
+
+@import '@fontsource/inter/400.css';
+@import '@fontsource/inter/500.css';
+@import '@fontsource/inter/600.css';
+@import '@fontsource/inter/700.css';
+@import '@fontsource/inter/800.css';
+
+/** **************************************
+* Plugins
+*************************************** */
+/* scrollbar */
+@import './components/scrollbar/styles.css';
+
+/* image */
+@import './components/image/styles.css';
+
+/* map */
+@import './components/map/styles.css';
+
+/* lightbox */
+@import './components/lightbox/styles.css';
+
+/* chart */
+@import './components/chart/styles.css';
+
+/** **************************************
+* Baseline
+*************************************** */
+html {
+ height: 100%;
+ -webkit-overflow-scrolling: touch;
+}
+body,
+#root,
+#root__layout {
+ display: flex;
+ flex: 1 1 auto;
+ min-height: 100%;
+ flex-direction: column;
+}
+img {
+ max-width: 100%;
+ vertical-align: middle;
+}
+ul {
+ margin: 0;
+ padding: 0;
+ list-style-type: none;
+}
+input[type='number'] {
+ -moz-appearance: textfield;
+ appearance: none;
+}
+input[type='number']::-webkit-outer-spin-button {
+ margin: 0;
+ -webkit-appearance: none;
+}
+input[type='number']::-webkit-inner-spin-button {
+ margin: 0;
+ -webkit-appearance: none;
+}
diff --git a/dashboard/src/hooks/use-boolean.ts b/dashboard/src/hooks/use-boolean.ts
new file mode 100644
index 00000000..d10ebd4b
--- /dev/null
+++ b/dashboard/src/hooks/use-boolean.ts
@@ -0,0 +1,42 @@
+'use client';
+
+import { useMemo, useState, useCallback } from 'react';
+
+// ----------------------------------------------------------------------
+
+export type UseBooleanReturn = {
+ value: boolean;
+ onTrue: () => void;
+ onFalse: () => void;
+ onToggle: () => void;
+ setValue: React.Dispatch>;
+};
+
+export function useBoolean(defaultValue: boolean = false): UseBooleanReturn {
+ const [value, setValue] = useState(defaultValue);
+
+ const onTrue = useCallback(() => {
+ setValue(true);
+ }, []);
+
+ const onFalse = useCallback(() => {
+ setValue(false);
+ }, []);
+
+ const onToggle = useCallback(() => {
+ setValue((prev) => !prev);
+ }, []);
+
+ const memoizedValue = useMemo(
+ () => ({
+ value,
+ onTrue,
+ onFalse,
+ onToggle,
+ setValue,
+ }),
+ [value, onTrue, onFalse, onToggle, setValue]
+ );
+
+ return memoizedValue;
+}
diff --git a/dashboard/src/hooks/use-client-rect.ts b/dashboard/src/hooks/use-client-rect.ts
new file mode 100644
index 00000000..fd2b1dd6
--- /dev/null
+++ b/dashboard/src/hooks/use-client-rect.ts
@@ -0,0 +1,76 @@
+import { useRef, useMemo, useState, useEffect, useCallback, useLayoutEffect } from 'react';
+
+import { useEventListener } from './use-event-listener';
+
+// ----------------------------------------------------------------------
+
+type ScrollElValue = {
+ scrollWidth: number;
+ scrollHeight: number;
+};
+
+type DOMRectValue = {
+ top: number;
+ right: number;
+ bottom: number;
+ left: number;
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+};
+
+export type UseClientRectReturn = DOMRectValue &
+ ScrollElValue & {
+ elementRef: React.RefObject;
+ };
+
+export function useClientRect(inputRef?: React.RefObject): UseClientRectReturn {
+ const initialRef = useRef(null);
+
+ const elementRef = inputRef || initialRef;
+
+ const [rect, setRect] = useState