App directory #4

This commit is contained in:
Darren Clarke 2023-06-28 12:55:24 +00:00 committed by GitHub
parent 69706053c6
commit 4d743c5e67
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
86 changed files with 223 additions and 107 deletions

View file

@ -0,0 +1,88 @@
"use client";
import { FC, useEffect } from "react";
import { CircularProgress, Typography, Grid } from "@mui/material";
import { signIn, signOut, getSession } from "next-auth/react";
import { useLogin, useTranslate } from "react-admin";
export const authProvider = {
login(o: any) {
if (o.ok) return Promise.resolve();
return Promise.reject();
},
async logout() {
const session = await getSession();
if (session) {
await signOut();
}
},
checkError(e: any) {
if (e.graphQLErrors && e.graphQLErrors.length > 0) {
const permDenied = e.graphQLErrors.some((e: any) =>
e.message.match(/.*permission denied.*/)
);
if (permDenied)
// eslint-disable-next-line prefer-promise-reject-errors
return Promise.reject({ message: "auth.permissionDenied" });
}
if (e.networkError && e.networkError.statusCode === 401) {
return Promise.reject();
}
return Promise.resolve();
},
async checkAuth() {
const session = await getSession();
if (!session) {
throw new Error("Invalid session");
}
},
async getIdentity() {
const session = await getSession();
if (!session) throw new Error("Invalid session");
return {
id: session.user?.email,
fullName: session.user?.name,
avatar: session.user?.image,
};
},
getPermissions: () => Promise.resolve(),
};
export const AdminLogin: FC = () => {
const reactAdminLogin = useLogin();
const translate = useTranslate();
useEffect(() => {
(async () => {
const session = await getSession();
if (session) {
reactAdminLogin({ ok: true });
} else {
signIn();
}
})();
});
return (
<Grid
container
spacing={5}
direction="column"
alignItems="center"
justifyContent="center"
style={{ minHeight: "100vh" }}
>
<Grid item xs={3}>
<Typography variant="h4" color="textSecondary">
{translate("auth.loggingIn")}
</Typography>
</Grid>
<Grid item xs={3}>
<CircularProgress size={80} />
</Grid>
</Grid>
);
};

View file

@ -0,0 +1,22 @@
"use client";
import { FC, PropsWithChildren, useEffect } from "react";
import { CircularProgress } from "@mui/material";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
export const Auth: FC<PropsWithChildren> = ({ children }) => {
const router = useRouter();
const { data: session, status: loading } = useSession();
useEffect(() => {
if (!session && !loading) {
router.push("/login");
}
}, [session, loading, router]);
if (loading) {
return <CircularProgress />;
}
return <>{children}</>;
};

View file

@ -0,0 +1,27 @@
.input {
width: 40px;
height: 60px;
margin: 5px;
font-size: 1.4rem;
padding: 0 9px 0 12px;
border: none;
outline: none;
background: white;
font-weight: 400;
color: rgba(59, 59, 59, 0.788);
-webkit-box-shadow: 2px 2px 2px 1px #d6d6d6, -1px -1px 1px #e6e6e6;
box-shadow: 2px 2px 2px 1px #d6d6d6, -1px -1px 1px #e6e6e6;
border-radius: 5px;
}
.group {
margin-top: 1.6rem;
}
.hyphen {
background: black;
height: 0.1em;
width: 1em;
margin: 0 0.5em;
display: inline-block;
}

View file

@ -0,0 +1,62 @@
"use client";
/* eslint-disable react/display-name */
import { forwardRef } from "react";
import useDigitInput, { InputAttributes } from "react-digit-input";
import styles from "./DigitInput.module.css";
const DigitInputElement = forwardRef<
HTMLInputElement,
Omit<InputAttributes, "ref"> & {
autoFocus?: boolean;
}
>(({ ...props }, ref): any => (
<>
<input
aria-label="verification code"
className={styles.input}
{...props}
ref={ref}
inputMode="decimal"
/>
</>
));
const DigitSeparator = forwardRef<
HTMLInputElement,
Omit<InputAttributes, "ref"> & {
autoFocus?: boolean;
}
>(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
({ ...props }, ref): any => (
<>
<span className={styles.hyphen} ref={ref} />
</>
)
);
export const SixDigitInput = ({ value, onChange }: any) => {
const digits = useDigitInput({
acceptedCharacters: /^\d$/,
length: 6,
value,
onChange,
});
return (
<div>
<div className={styles.group}>
<DigitInputElement autoFocus {...digits[0]} />
<DigitInputElement {...digits[1]} />
<DigitInputElement {...digits[2]} />
<DigitSeparator />
<DigitInputElement {...digits[3]} />
<DigitInputElement {...digits[4]} />
<DigitInputElement {...digits[5]} />
</div>
<pre hidden>
<code>{value}</code>
</pre>
</div>
);
};

View file

@ -0,0 +1,69 @@
"use client";
import { FC, useEffect, useState } from "react";
import { Admin, Resource } from "react-admin";
import { useApolloClient } from "@apollo/client";
import polyglotI18nProvider from "ra-i18n-polyglot";
import { ThemeProvider, createTheme } from "@mui/material";
import { metamigoDataProvider } from "../_lib/dataprovider";
import { theme } from "./layout/themes";
import { Layout } from "./layout";
import englishMessages from "../_i18n/en";
import users from "./users";
import accounts from "./accounts";
import whatsappBots from "./whatsapp/bots";
import whatsappMessages from "./whatsapp/messages";
import whatsappAttachments from "./whatsapp/attachments";
import voiceLines from "./voice/voicelines";
import signalBots from "./signal/bots";
import voiceProviders from "./voice/providers";
import webhooks from "./webhooks";
import { AdminLogin, authProvider } from "./AdminLogin";
const i18nProvider = polyglotI18nProvider(
(_locale: any) => englishMessages,
"en"
);
const MetamigoAdmin: FC = () => {
// eslint-disable-next-line unicorn/no-null
const [dataProvider, setDataProvider] = useState(null);
const client = useApolloClient();
const muiTheme = createTheme(theme);
useEffect(() => {
(async () => {
const dataProvider = await metamigoDataProvider(client);
// @ts-ignore
setDataProvider(() => dataProvider);
})();
}, [client]);
return (
dataProvider && (
<ThemeProvider theme={muiTheme}>
<Admin
disableTelemetry
dataProvider={dataProvider}
layout={Layout}
i18nProvider={i18nProvider}
loginPage={AdminLogin}
// @ts-ignore
authProvider={authProvider}
>
<Resource name="webhooks" {...webhooks} />
<Resource name="whatsappBots" {...whatsappBots} />
<Resource name="whatsappMessages" {...whatsappMessages} />
<Resource name="whatsappAttachments" {...whatsappAttachments} />
<Resource name="signalBots" {...signalBots} />
<Resource name="voiceProviders" {...voiceProviders} />
<Resource name="voiceLines" {...voiceLines} />
<Resource name="users" {...users} />
<Resource name="accounts" {...accounts} />
<Resource name="languages" />
</Admin>
</ThemeProvider>
)
);
};
export default MetamigoAdmin;

View file

@ -0,0 +1,8 @@
"use client";
import { FC, PropsWithChildren } from "react";
import { SessionProvider } from "next-auth/react";
export const MultiProvider: FC<PropsWithChildren> = ({ children }) => (
<SessionProvider>{children}</SessionProvider>
);

View file

@ -0,0 +1,61 @@
"use client";
import { FC } from "react";
import { makeStyles } from "@mui/styles";
import {
SimpleForm,
TextInput,
Edit,
ReferenceInput,
SelectInput,
DateInput,
Toolbar,
DeleteButton,
EditProps,
} from "react-admin";
import { useSession } from "next-auth/react";
const useStyles = makeStyles((_theme: any) => ({
defaultToolbar: {
flex: 1,
display: "flex",
justifyContent: "space-between",
},
}));
type AccountEditToolbarProps = {
record?: any;
};
const AccountEditToolbar: FC<AccountEditToolbarProps> = (props: any) => {
const { data: session } = useSession();
const classes = useStyles(props);
return (
<Toolbar className={classes.defaultToolbar} {...props}>
<DeleteButton disabled={session?.user?.email === props.record?.userId} />
</Toolbar>
);
};
const AccountTitle = ({ record }: { record?: any }) => {
let title = "";
if (record) title = record.name ?? record.email;
return <span>Account {title}</span>;
};
export const AccountEdit = (props: EditProps) => (
<Edit title={<AccountTitle />} {...props}>
<SimpleForm toolbar={<AccountEditToolbar />}>
<TextInput disabled source="id" />
<ReferenceInput source="userId" reference="users">
<SelectInput disabled optionText="email" />
</ReferenceInput>
<TextInput disabled source="providerType" />
<TextInput disabled source="providerId" />
<TextInput disabled source="providerAccountId" />
<DateInput disabled source="createdAt" />
<DateInput disabled source="updatedAt" />
</SimpleForm>
</Edit>
);
export default AccountEdit;

View file

@ -0,0 +1,46 @@
"use client";
import { FC } from "react";
import {
List,
Datagrid,
DateField,
TextField,
ReferenceField,
DeleteButton,
ListProps,
} from "react-admin";
import { useSession } from "next-auth/react";
type DeleteNotSelfButtonProps = {
record?: any;
};
const DeleteNotSelfButton: FC<DeleteNotSelfButtonProps> = (props: any) => {
const { data: session } = useSession();
return (
// @ts-ignore
<DeleteButton
disabled={session?.user?.email === props.record.userId}
{...props}
/>
);
};
export const AccountList = (props: ListProps) => (
<List {...props} exporter={false}>
<Datagrid rowClick="edit">
<ReferenceField source="userId" reference="users">
<TextField source="email" />
</ReferenceField>
<TextField source="providerType" />
<TextField source="providerId" />
<TextField source="providerAccountId" />
<DateField source="createdAt" />
<DateField source="updatedAt" />
<DeleteNotSelfButton />
</Datagrid>
</List>
);
export default AccountList;

View file

@ -0,0 +1,13 @@
"use client";
/* eslint-disable import/no-named-as-default */
/* eslint-disable import/no-anonymous-default-export */
import AccountIcon from "@mui/icons-material/AccountTree";
import AccountList from "./AccountList";
import AccountEdit from "./AccountEdit";
export default {
list: AccountList,
edit: AccountEdit,
icon: AccountIcon,
};

View file

@ -0,0 +1,57 @@
"use client";
import { forwardRef } from "react";
import { AppBar, UserMenu, MenuItemLink, useTranslate } from "react-admin";
import Typography from "@mui/material/Typography";
import SettingsIcon from "@mui/icons-material/Settings";
import { makeStyles } from "@mui/styles";
const useStyles = makeStyles({
title: {
flex: 1,
textOverflow: "ellipsis",
whiteSpace: "nowrap",
overflow: "hidden",
},
spacer: {
flex: 1,
},
});
// eslint-disable-next-line react/display-name
const ConfigurationMenu = forwardRef<any, any>((props, ref) => {
const translate = useTranslate();
return (
<MenuItemLink
ref={ref}
to="/configuration"
primaryText={translate("pos.configuration")}
leftIcon={<SettingsIcon />}
onClick={props.onClick}
sidebarIsOpen
/>
);
});
const CustomUserMenu = (props: any) => (
<UserMenu {...props}>
<ConfigurationMenu />
</UserMenu>
);
const CustomAppBar = (props: any) => {
const classes = useStyles();
return (
<AppBar {...props} elevation={1} userMenu={<CustomUserMenu />}>
<Typography
variant="h6"
color="inherit"
className={classes.title}
id="react-admin-title"
/>
<span className={classes.spacer} />
</AppBar>
);
};
export default CustomAppBar;

View file

@ -0,0 +1,19 @@
"use client";
import { Layout as RaLayout, LayoutProps, Sidebar } from "react-admin";
import AppBar from "./AppBar";
import Menu from "./Menu";
import { theme } from "./themes";
const CustomSidebar = (props: any) => <Sidebar {...props} size={200} />;
export const Layout = (props: LayoutProps) => (
<RaLayout
{...props}
appBar={AppBar}
menu={Menu}
sidebar={CustomSidebar}
// @ts-ignore
theme={theme}
/>
);

View file

@ -0,0 +1,106 @@
"use client";
import { SVGProps } from "react";
const Logo = (props: SVGProps<SVGSVGElement>) => (
<svg width="220.001" height="43.659" {...props}>
<path d="M59.39 24.586h4.6v8.512c-1.058.2-3.743.57-5.742.57-6.398 0-7.74-3.77-7.74-11.452 0-7.827 1.4-11.54 7.797-11.54 3.627 0 8.597.828 8.597.828l.115-2.542s-4.885-1.056-9.083-1.056c-8.312 0-10.626 5.112-10.626 14.31 0 8.968 2.228 14.167 10.711 14.167 3.028 0 8.17-.8 8.998-.971V21.816H59.39zm13.14 11.397h2.998V21.302s3.514-1.943 7.284-2.714V15.56c-3.828.743-7.312 3.142-7.312 3.142v-2.713h-2.97zm27.962-13.967c0-4.342-1.913-6.427-6.455-6.427-3.427 0-7.826.885-7.826.885l.114 2.285s4.77-.542 7.57-.542c2.4 0 3.598 1 3.598 3.799v1.742l-6.284.6c-4.113.4-6.112 2.056-6.112 5.912 0 4.028 2 6.113 5.627 6.113 3.6 0 7.198-1.6 7.198-1.6 1.2 1.2 2.656 1.6 4.77 1.6l.114-2.37c-1.285-.144-2.228-.6-2.314-1.743zm-2.999 3.998v6.599s-3.313 1.256-6.284 1.256c-2.028 0-3.027-1.37-3.027-3.684 0-2.2.942-3.37 3.4-3.6zm17.738-10.425c-2.828 0-5.855 1.4-5.855 1.4V7.277h-2.97v28.677s4.283.429 6.683.429c7.283 0 9.425-2.77 9.425-10.711 0-7.198-1.828-10.083-7.283-10.083zm-2.2 18.109c-1.056 0-3.655-.2-3.655-.2V19.416s2.8-1.142 5.54-1.142c3.514 0 4.57 2.228 4.57 7.398 0 5.598-.97 8.026-6.454 8.026zm28.535-11.682c0-4.342-1.942-6.427-6.455-6.427-3.428 0-7.826.885-7.826.885l.114 2.285s4.77-.542 7.57-.542c2.4 0 3.598 1 3.598 3.799v1.742l-6.284.6c-4.113.4-6.112 2.056-6.112 5.912 0 4.028 2 6.113 5.626 6.113 3.6 0 7.198-1.6 7.198-1.6 1.2 1.2 2.628 1.6 4.77 1.6l.115-2.37c-1.286-.144-2.257-.6-2.314-1.743zm-3 3.998v6.599s-3.34 1.256-6.283 1.256c-2.057 0-3.056-1.37-3.056-3.684 0-2.2.97-3.37 3.4-3.6zm24.25-18.737h-2.94v8.826c-.6-.114-3.2-.514-4.914-.514-6.084 0-8.369 3.513-8.369 10.568 0 8.626 3.285 10.226 7.198 10.226 3 0 6.084-1.771 6.084-1.771v1.37h2.942zm-8.654 26.42c-2.37 0-4.484-1.084-4.484-7.54 0-5.198 1.228-7.97 5.427-7.97 1.657 0 4.113.373 4.77.487v13.539s-2.885 1.485-5.713 1.485zM176.3 15.59c-6.313 0-8.54 3.285-8.54 10.168 0 7.255 1.827 10.626 8.54 10.626 6.77 0 8.57-3.37 8.57-10.626 0-6.883-2.2-10.168-8.57-10.168zm0 18.195c-4.713 0-5.484-2.371-5.484-8.027 0-5.57 1.256-7.57 5.484-7.57 4.284 0 5.484 2 5.484 7.57 0 5.656-.714 8.027-5.484 8.027zm13.453 2.199h3V21.303s3.512-1.943 7.254-2.714V15.56c-3.828.743-7.312 3.142-7.312 3.142V15.99h-2.942zm27.934-13.967c0-4.342-1.913-6.427-6.426-6.427-3.456 0-7.855.885-7.855.885l.143 2.285s4.741-.542 7.54-.542c2.4 0 3.6 1 3.6 3.799v1.742l-6.285.6c-4.113.4-6.112 2.056-6.112 5.912 0 4.028 2 6.113 5.655 6.113 3.6 0 7.198-1.6 7.198-1.6 1.2 1.2 2.628 1.6 4.742 1.6l.114-2.37c-1.257-.144-2.228-.6-2.314-1.743zm-2.999 3.998v6.599s-3.313 1.256-6.284 1.256c-2.028 0-3.027-1.37-3.027-3.684 0-2.2.97-3.37 3.4-3.6z" />
<defs>
<linearGradient
gradientTransform="rotate(25)"
id="a"
x1="0"
y1="0"
x2="1"
y2="0"
>
<stop offset="0%" stopColor="#8C48D2" />
<stop offset="100%" stopColor="#CF705A" />
</linearGradient>
<linearGradient
xlinkHref="#a"
id="c"
gradientTransform="scale(.7746 1.291)"
x1="15.492"
y1="4.648"
x2="23.238"
y2="4.648"
gradientUnits="userSpaceOnUse"
/>
<linearGradient
xlinkHref="#a"
id="d"
gradientTransform="scale(1.27 .7874)"
x1="7.874"
y1="15.24"
x2="15.748"
y2="15.24"
gradientUnits="userSpaceOnUse"
/>
<linearGradient
xlinkHref="#a"
id="e"
gradientTransform="scale(.91287 1.09545)"
x1="10.954"
y1="7.303"
x2="21.909"
y2="7.303"
gradientUnits="userSpaceOnUse"
/>
<linearGradient
xlinkHref="#a"
id="f"
gradientTransform="scale(1.13606 .88024)"
x1="3.521"
y1="13.576"
x2="22.886"
y2="13.576"
gradientUnits="userSpaceOnUse"
/>
<linearGradient
xlinkHref="#a"
id="g"
gradientTransform="scale(1.029 .97183)"
x1="5.831"
y1="1.029"
x2="23.324"
y2="1.029"
gradientUnits="userSpaceOnUse"
/>
<linearGradient
xlinkHref="#a"
id="b"
gradientTransform="scale(.88647 1.12807)"
x1="4.512"
y1=".886"
x2="29.33"
y2=".886"
gradientUnits="userSpaceOnUse"
/>
</defs>
<g transform="translate(-6.238 -1.56) scale(1.55946)" fill="url(#b)">
<path
d="M12 9v4a3 3 0 006 0V9a3 3 0 00-6 0zm3-2a2 2 0 012 2v4a2 2 0 11-4 0V9a2 2 0 012-2z"
fill="url(#c)"
/>
<path
d="M10 13.2a5 5 0 0010 0v-.7a.5.5 0 10-1 0v.7a4 4 0 11-8 0v-.7a.5.5 0 10-1 0z"
fill="url(#d)"
/>
<path
d="M19.5 13a.5.5 0 100-1h-9a.5.5 0 100 1zm-3 6a.5.5 0 110 1h-3a.5.5 0 110-1h1v-1h1v1zm-3-10a.5.5 0 000-1h-1v1zm0 2a.5.5 0 000-1h-1v1zm3 0a.5.5 0 110-1h1v1zm0-2a.5.5 0 110-1h1v1z"
fill="url(#e)"
/>
<path
d="M25.947 14.272a.51.51 0 01.053.23v13.994a.5.5 0 01-.5.5h-21a.5.5 0 01-.5-.5V14.502a.502.502 0 01.2-.406L7 11.95v1.26l-2 1.533v1.253l6.667 5h6.666l6.667-5v-1.253l-2-1.533v-1.26l2.8 2.146a.502.502 0 01.147.176zM10.739 21.55L5 27.29V17.245l5.739 4.304zm.968.446h6.586l6 6H5.707zm7.554-.446L25 17.246V27.29l-5.739-5.739z"
fill="url(#f)"
/>
<path
d="M24 6.2a.5.5 0 00-.146-.354l-4.7-4.7A.5.5 0 0018.8 1H6.5a.5.5 0 00-.5.5V18h1V2h11v4.5a.5.5 0 00.5.5H23v11h1zM19 6V2.41L22.59 6z"
fill="url(#g)"
/>
</g>
</svg>
);
export default Logo;

View file

@ -0,0 +1,134 @@
"use client";
/* eslint-disable camelcase */
import { FC, useState } from "react";
// import { useSelector } from "react-redux";
import SecurityIcon from "@mui/icons-material/Security";
import VoiceIcon from "@mui/icons-material/PhoneInTalk";
import { Box } from "@mui/material";
import { useTheme } from "@mui/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTranslate, MenuItemLink } from "react-admin";
import users from "../users";
import accounts from "../accounts";
import webhooks from "../webhooks";
import voiceLines from "../voice/voicelines";
import voiceProviders from "../voice/providers";
import whatsappBots from "../whatsapp/bots";
import signalBots from "../signal/bots";
import { SubMenu } from "./SubMenu";
type MenuName = "menuVoice" | "menuSecurity";
export const Menu: FC = ({ onMenuClick, logout, dense = false }: any) => {
const [state, setState] = useState({
menuVoice: false,
menuSecurity: false,
});
const translate = useTranslate();
const theme = useTheme();
// @ts-ignore
const isXSmall = useMediaQuery(theme.breakpoints.down("xs"));
const open = true; // useSelector((state: any) => state.admin.ui.sidebarOpen);
// useSelector((state: any) => state.theme); // force rerender on theme change
const handleToggle = (menu: MenuName) => {
setState((state: any) => ({ ...state, [menu]: !state[menu] }));
};
return (
<Box mt={1}>
<MenuItemLink
to={`/whatsappbots`}
primaryText={translate(`pos.menu.whatsapp`, {
smart_count: 2,
})}
leftIcon={<whatsappBots.icon />}
onClick={onMenuClick}
sidebarIsOpen={open}
dense={dense}
/>
<MenuItemLink
to={`/signalbots`}
primaryText={translate(`pos.menu.signal`, {
smart_count: 2,
})}
leftIcon={<signalBots.icon />}
onClick={onMenuClick}
sidebarIsOpen={open}
dense={dense}
/>
<SubMenu
handleToggle={() => handleToggle("menuVoice")}
isOpen={state.menuVoice}
sidebarIsOpen={open}
name="pos.menu.voice"
icon={<VoiceIcon />}
dense={dense}
>
<MenuItemLink
to={`/voiceproviders`}
primaryText={translate(`resources.providers.name`, {
smart_count: 2,
})}
leftIcon={<voiceProviders.icon />}
onClick={onMenuClick}
sidebarIsOpen={open}
dense={dense}
/>
<MenuItemLink
to={`/voicelines`}
primaryText={translate(`resources.voicelines.name`, {
smart_count: 2,
})}
leftIcon={<voiceLines.icon />}
onClick={onMenuClick}
sidebarIsOpen={open}
dense={dense}
/>
</SubMenu>
<MenuItemLink
to={`/webhooks`}
primaryText={translate(`resources.webhooks.name`, {
smart_count: 2,
})}
leftIcon={<webhooks.icon />}
onClick={onMenuClick}
sidebarIsOpen={open}
dense={dense}
/>
<SubMenu
handleToggle={() => handleToggle("menuSecurity")}
isOpen={state.menuSecurity}
sidebarIsOpen={open}
name="pos.menu.security"
icon={<SecurityIcon />}
dense={dense}
>
<MenuItemLink
to={`/users`}
primaryText={translate(`resources.users.name`, {
smart_count: 2,
})}
leftIcon={<users.icon />}
onClick={onMenuClick}
sidebarIsOpen={open}
dense={dense}
/>
<MenuItemLink
to={`/accounts`}
primaryText={translate(`resources.accounts.name`, {
smart_count: 2,
})}
leftIcon={<accounts.icon />}
onClick={onMenuClick}
sidebarIsOpen={open}
dense={dense}
/>
</SubMenu>
{isXSmall && logout}
</Box>
);
};
export default Menu;

View file

@ -0,0 +1,86 @@
"use client";
import { FC, PropsWithChildren, Fragment, ReactElement } from "react";
import ExpandMore from "@mui/icons-material/ExpandMore";
import List from "@mui/material/List";
import MenuItem from "@mui/material/MenuItem";
import ListItemIcon from "@mui/material/ListItemIcon";
import Typography from "@mui/material/Typography";
import Collapse from "@mui/material/Collapse";
import Tooltip from "@mui/material/Tooltip";
import { makeStyles } from "@mui/styles";
import { useTranslate } from "react-admin";
const useStyles = makeStyles((theme: any) => ({
icon: { minWidth: theme.spacing(5) },
sidebarIsOpen: {
"& a": {
paddingLeft: theme.spacing(4),
transition: "padding-left 195ms cubic-bezier(0.4, 0, 0.6, 1) 0ms",
},
},
sidebarIsClosed: {
"& a": {
paddingLeft: theme.spacing(2),
transition: "padding-left 195ms cubic-bezier(0.4, 0, 0.6, 1) 0ms",
},
},
}));
type SubMenuProps = PropsWithChildren<{
dense: boolean;
handleToggle: () => void;
icon: ReactElement;
isOpen: boolean;
name: string;
sidebarIsOpen: boolean;
}>;
export const SubMenu: FC<SubMenuProps> = ({
handleToggle,
sidebarIsOpen,
isOpen,
name,
icon,
children,
dense,
}: any) => {
const translate = useTranslate();
const classes = useStyles();
const header = (
// @ts-ignore
<MenuItem dense={dense} button onClick={handleToggle}>
<ListItemIcon className={classes.icon}>
{isOpen ? <ExpandMore /> : icon}
</ListItemIcon>
<Typography variant="inherit" color="textSecondary">
{translate(name)}
</Typography>
</MenuItem>
);
return (
<Fragment>
{sidebarIsOpen || isOpen ? (
header
) : (
<Tooltip title={translate(name)} placement="right">
{header}
</Tooltip>
)}
<Collapse in={isOpen} timeout="auto" unmountOnExit>
<List
dense={dense}
component="div"
disablePadding
className={
sidebarIsOpen ? classes.sidebarIsOpen : classes.sidebarIsClosed
}
>
{children}
</List>
</Collapse>
</Fragment>
);
};

View file

@ -0,0 +1,5 @@
"use client";
export { default as AppBar } from "./AppBar";
export { Layout } from "./Layout";
export { default as Menu } from "./Menu";

View file

@ -0,0 +1,99 @@
export const theme = {
spacing: () => 8,
palette: {
primary: {
main: "#337799",
},
secondary: {
light: "#5f5fc4",
main: "#283593",
dark: "#001064",
contrastText: "#fff",
},
background: {
default: "#fff",
},
getContrastText(color: string) {
return color === "#ffffff" ? "#000" : "#fff";
},
},
shape: {
borderRadius: 5,
},
breakpoints: {
up: (key: any) => `@media (min-width:${key})`,
down: (key: any) => `@media (max-width:${key})`,
},
transitions: {
create(props: any) {
return `all ${props.duration}ms ${props.easing}`;
},
easing: {
easeInOut: "cubic-bezier(0.4, 0, 0.2, 1)",
easeOut: "cubic-bezier(0.0, 0, 0.2, 1)",
easeIn: "cubic-bezier(0.4, 0, 1, 1)",
sharp: "cubic-bezier(0.4, 0, 0.6, 1)",
},
duration: {
shortest: 150,
shorter: 200,
short: 250,
standard: 300,
complex: 375,
enteringScreen: 225,
leavingScreen: 195,
},
},
typography: {
h6: { fontSize: 16, fontWeight: 600, color: "#1bb1bb" },
},
overrides: {
RaMenuItemLink: {
root: {
borderLeft: "3px solid #fff",
},
active: {
borderLeft: "3px solid #ef7706",
},
},
MuiPaper: {
elevation1: {
boxShadow: "none",
},
root: {
border: "1px solid #e0e0e3",
backgroundClip: "padding-box",
},
},
MuiButton: {
contained: {
backgroundColor: "#fff",
color: "#4f3cc9",
boxShadow: "none",
},
},
MuiAppBar: {
colorSecondary: {
color: "#fff",
backgroundColor: "#337799",
border: 0,
},
},
MuiLinearProgress: {
colorPrimary: {
backgroundColor: "#f5f5f5",
},
barColorPrimary: {
backgroundColor: "#d7d7d7",
},
},
MuiFilledInput: {
root: {
backgroundColor: "rgba(0, 0, 0, 0.04)",
"&$disabled": {
backgroundColor: "rgba(0, 0, 0, 0.04)",
},
},
},
},
};

View file

@ -0,0 +1,27 @@
.input {
width: 40px;
height: 60px;
margin: 5px;
font-size: 1.4rem;
padding: 0 9px 0 12px;
border: none;
outline: none;
background: white;
font-weight: 400;
color: rgba(59, 59, 59, 0.788);
-webkit-box-shadow: 2px 2px 2px 1px #d6d6d6, -1px -1px 1px #e6e6e6;
box-shadow: 2px 2px 2px 1px #d6d6d6, -1px -1px 1px #e6e6e6;
border-radius: 5px;
}
.group {
margin-top: 1.6rem;
}
.hyphen {
background: black;
height: 0.1em;
width: 1em;
margin: 0 0.5em;
display: inline-block;
}

View file

@ -0,0 +1,36 @@
"use client";
import {
SimpleForm,
Create,
TextInput,
required,
CreateProps,
} from "react-admin";
import { useSession } from "next-auth/react";
import { validateE164Number } from "../../../_lib/phone-numbers";
const SignalBotCreate = (props: CreateProps) => {
const { data: session } = useSession();
return (
<Create {...props} title="Create Signal Bot">
<SimpleForm>
<TextInput
source="userId"
defaultValue={
// @ts-expect-error: ID does exist
session.user.id
}
/>
<TextInput
source="phoneNumber"
validate={[validateE164Number, required()]}
/>
<TextInput source="description" />
</SimpleForm>
</Create>
);
};
export default SignalBotCreate;

View file

@ -0,0 +1,14 @@
"use client";
import { SimpleForm, Edit, TextInput, required, EditProps } from "react-admin";
const SignalBotEdit = (props: EditProps) => (
<Edit {...props} title="Edit Bot">
<SimpleForm>
<TextInput disabled source="phoneNumber" validate={[required()]} />
<TextInput source="description" />
</SimpleForm>
</Edit>
);
export default SignalBotEdit;

View file

@ -0,0 +1,25 @@
"use client";
import {
List,
Datagrid,
DateField,
TextField,
BooleanField,
ListProps,
} from "react-admin";
const SignalBotList = (props: ListProps) => (
<List {...props} exporter={false}>
<Datagrid rowClick="show">
<TextField source="phoneNumber" />
<TextField source="description" />
<BooleanField source="isVerified" />
<DateField source="createdAt" />
<DateField source="updatedAt" />
<TextField source="createdBy" />
</Datagrid>
</List>
);
export default SignalBotList;

View file

@ -0,0 +1,477 @@
"use client";
import React, { useState } from "react";
import {
Show,
SimpleShowLayout,
BooleanField,
TextField,
ShowProps,
EditButton,
TopToolbar,
useTranslate,
useRefresh,
} from "react-admin";
import {
TextField as MuiTextField,
Button,
Card,
Grid,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Typography,
Box,
CircularProgress,
} from "@mui/material";
import { SixDigitInput } from "../../DigitInput";
import {
sanitizeE164Number,
isValidE164Number,
} from "../../../_lib/phone-numbers";
const Sidebar = ({ record }: any) => {
const [phoneNumber, setPhoneNumber] = useState("");
const [errorNumber, setErrorNumber] = useState(false);
const handlePhoneNumberChange = (event: any) => {
setPhoneNumber(event.target.value);
};
const [message, setMessage] = useState("");
const handleMessageChange = (event: any) => {
setMessage(event.target.value);
};
const sendMessage = async (phoneNumber: string, message: string) => {
await fetch(`/api/v1/signal/bots/${record.token}/send`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ phoneNumber, message }),
});
};
const resetSession = async (phoneNumber: string) => {
await fetch(`/api/v1/signal/bots/${record.token}/resetSession`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ phoneNumber }),
});
};
const handleBlurNumber = () => {
setErrorNumber(!isValidE164Number(sanitizeE164Number(phoneNumber)));
};
const handleSend = () => {
const sanitized = sanitizeE164Number(phoneNumber);
if (isValidE164Number(sanitized)) {
setErrorNumber(false);
sendMessage(sanitized, message);
} else setErrorNumber(false);
};
const handleResetSession = () => {
const sanitized = sanitizeE164Number(phoneNumber);
if (isValidE164Number(sanitized)) {
setErrorNumber(false);
resetSession(sanitized);
} else setErrorNumber(false);
};
return (
<Card style={{ width: "33%", marginLeft: 20, padding: 14 }}>
<Grid container direction="column" spacing={2}>
<Grid item>
<Typography variant="h6">Send message</Typography>
</Grid>
<Grid item>
<MuiTextField
variant="outlined"
label="Phone number"
fullWidth
size="small"
error={errorNumber}
onBlur={handleBlurNumber}
value={phoneNumber}
onChange={handlePhoneNumberChange}
/>
</Grid>
<Grid item>
<MuiTextField
variant="outlined"
label="Message"
multiline
minRows={3}
fullWidth
size="small"
value={message}
onChange={handleMessageChange}
/>
</Grid>
<Grid item container direction="row-reverse">
<Button
variant="contained"
color="primary"
onClick={() => handleSend()}
>
Send
</Button>
<Button variant="contained" onClick={() => handleResetSession()}>
Reset Session
</Button>
</Grid>
</Grid>
</Card>
);
};
const MODE = {
SMS: "SMS",
VOICE: "VOICE",
};
const handleRequestCode = async ({
verifyMode,
id,
onSuccess,
onError,
captchaCode = undefined,
}: any) => {
if (verifyMode === MODE.SMS) console.log("REQUESTING sms");
else if (verifyMode === MODE.VOICE) console.log("REQUESTING voice");
let response: Response;
let url = `/api/v1/signal/bots/${id}/requestCode?mode=${verifyMode.toLowerCase()}`;
if (captchaCode) {
url += `&captcha=${captchaCode}`;
}
try {
response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (response && response.ok) {
onSuccess();
} else {
onError(response.status || 400);
}
} catch (error: any) {
console.error("Failed to request verification code:", error);
}
};
const VerificationCodeRequest = ({
verifyMode,
data,
onSuccess,
onError,
}: any) => {
React.useEffect(() => {
(async () => {
await handleRequestCode({
verifyMode,
id: data.id,
onSuccess,
onError,
});
})();
}, [data.id, onError, onSuccess, verifyMode]);
return (
<>
<DialogTitle id="form-dialog-title">
Requesting code for {data.phoneNumber}
</DialogTitle>
<DialogContent>
<Box display="flex">
<Box m="auto">
<CircularProgress />
</Box>
</Box>
</DialogContent>
</>
);
};
const VerificationCaptcha = ({
verifyMode,
data,
onSuccess,
onError,
handleClose,
}: any) => {
const [code, setCode] = React.useState(undefined);
const [isSubmitting, setSubmitting] = React.useState(false);
const handleSubmitVerification = async () => {
setSubmitting(true);
await handleRequestCode({
verifyMode,
id: data.id,
onSuccess,
onError,
captchaCode: code,
});
setSubmitting(false);
};
const handleCaptchaChange = (value: any) => {
if (value)
setCode(
value
.replace(/signalcaptcha:\/\//, "")
.replace("“", "")
.replace("”", "")
.trim()
);
else setCode(value);
};
return (
<>
<DialogTitle id="form-dialog-title">
Captcha for {data.phoneNumber}
</DialogTitle>
<DialogContent>
<MuiTextField
value={code}
onChange={(ev) => handleCaptchaChange(ev.target.value)}
/>
</DialogContent>
<DialogActions>
{isSubmitting && <CircularProgress />}
{!isSubmitting && (
<Button onClick={handleClose} color="primary">
Cancel
</Button>
)}
{!isSubmitting && (
<Button onClick={handleSubmitVerification} color="primary">
Request
</Button>
)}
</DialogActions>
</>
);
};
const VerificationCodeInput = ({
data,
verifyMode,
handleClose,
handleRestartVerification,
confirmVerification,
}: any) => {
const [code, setValue] = React.useState("");
const [isSubmitting, setSubmitting] = React.useState(false);
const [isValid, setValid] = React.useState(false);
const [submissionError, setSubmissionError] = React.useState(undefined);
const translate = useTranslate();
const validator = (v: any) => v.trim().length === 6;
const handleValueChange = (newValue: any) => {
setValue(newValue);
setValid(validator(newValue));
};
const handleSubmitVerification = async () => {
setSubmitting(true);
// await sleep(2000)
const response = await fetch(
`/api/v1/signal/bots/${data.id}/register?code=${code}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
}
);
setSubmitting(false);
const responseBody = await response.json();
console.log(responseBody);
if (response.status === 200) {
confirmVerification();
} else if (responseBody.message)
// @ts-expect-error
setSubmissionError(`Error: ${responseBody.message}`);
else {
setSubmissionError(
// @ts-expect-error
"There was an error, sorry about that. Please try again later or contact support."
);
}
};
const title =
verifyMode === MODE.SMS
? translate("resources.signalBots.verifyDialog.sms", {
phoneNumber: data.phoneNumber,
})
: translate("resources.signalBots.verifyDialog.voice", {
phoneNumber: data.phoneNumber,
});
return (
<>
<DialogTitle id="form-dialog-title">
Verify {data.phoneNumber}
</DialogTitle>
<DialogContent>
<DialogContentText>{title}</DialogContentText>
<SixDigitInput value={code} onChange={handleValueChange} />
{submissionError && (
<Typography variant="body1" gutterBottom color="error">
{submissionError}
</Typography>
)}
</DialogContent>
<DialogActions>
{isSubmitting && <CircularProgress />}
{!isSubmitting && (
<Button onClick={handleClose} color="primary">
Cancel
</Button>
)}
{!isSubmitting && (
<Button onClick={handleRestartVerification} color="primary">
Restart
</Button>
)}
{!isSubmitting && (
<Button
onClick={handleSubmitVerification}
color="primary"
disabled={!isValid}
>
Verify
</Button>
)}
</DialogActions>
</>
);
};
const VerificationCodeDialog = (props: any) => {
const [stage, setStage] = React.useState("request");
const onRequestSuccess = () => setStage("verify");
const onRestartVerification = () => setStage("request");
const handleClose = () => {
setStage("request");
props.handleClose();
};
const onError = (code: number) => {
if (code === 402 || code === 500) {
setStage("captcha");
} else {
setStage("request");
}
};
return (
<Dialog
open={props.open}
onClose={props.handleClose}
aria-labelledby="form-dialog-title"
>
{props.open && stage === "request" && (
<VerificationCodeRequest
mode={props.verifyMode}
onSuccess={onRequestSuccess}
onError={onError}
{...props}
/>
)}
{props.open && stage === "verify" && (
<VerificationCodeInput
{...props}
handleRestartVerification={onRestartVerification}
handleClose={handleClose}
/>
)}
{props.open && stage === "captcha" && (
<VerificationCaptcha
mode={props.verifyMode}
onSuccess={onRequestSuccess}
onError={onRestartVerification}
handleClose={handleClose}
{...props}
/>
)}
</Dialog>
);
};
const SignalBotShowActions = ({ data }: any) => {
const [open, setOpen] = React.useState(false);
const [verifyMode, setVerifyMode] = React.useState("");
const refresh = useRefresh();
const handleOpenSMS = () => {
setVerifyMode(MODE.SMS);
setOpen(true);
};
const handleOpenVoice = () => {
setVerifyMode(MODE.VOICE);
setOpen(true);
};
const handleClose = () => setOpen(false);
const confirmVerification = () => {
setOpen(false);
refresh();
};
return (
<TopToolbar>
<EditButton record={data} />
{data && !data.isVerified && (
<Button onClick={handleOpenSMS} color="primary">
Verify with SMS
</Button>
)}
{data && !data.isVerified && (
<Button onClick={handleOpenVoice} color="primary">
Verify with Voice
</Button>
)}
{data && !data.isVerified && (
<VerificationCodeDialog
data={data}
verifyMode={verifyMode}
handleClose={handleClose}
open={open}
confirmVerification={confirmVerification}
/>
)}
</TopToolbar>
);
};
const SignalBotShow = (props: ShowProps) => (
<Show
actions={<SignalBotShowActions />}
{...props}
title="Signal Bot"
aside={<Sidebar />}
>
<SimpleShowLayout>
<TextField source="phoneNumber" />
<BooleanField source="isVerified" />
<TextField source="description" />
<TextField source="token" />
</SimpleShowLayout>
</Show>
);
export default SignalBotShow;

View file

@ -0,0 +1,16 @@
"use client";
import SignalBotIcon from "@mui/icons-material/ChatOutlined";
import SignalBotList from "./SignalBotList";
import SignalBotEdit from "./SignalBotEdit";
import SignalBotCreate from "./SignalBotCreate";
import SignalBotShow from "./SignalBotShow";
// eslint-disable-next-line import/no-anonymous-default-export
export default {
list: SignalBotList,
create: SignalBotCreate,
edit: SignalBotEdit,
show: SignalBotShow,
icon: SignalBotIcon,
};

View file

@ -0,0 +1,29 @@
"use client";
/* eslint-disable react/display-name */
import {
SelectInput,
required,
ReferenceInput,
ReferenceField,
TextField,
} from "react-admin";
export const SignalBotSelectInput = (source: string) => () =>
(
<ReferenceInput
label="Signal Bot"
source={source}
reference="signalBots"
validate={[required()]}
>
<SelectInput optionText="phoneNumber" />
</ReferenceInput>
);
export const SignalBotField = (source: string) => () =>
(
<ReferenceField label="Signal Bot" reference="signalBots" source={source}>
<TextField source="phoneNumber" />
</ReferenceField>
);

View file

@ -0,0 +1,29 @@
"use client";
import { FC } from "react";
import {
SimpleForm,
TextInput,
BooleanInput,
Create,
CreateProps,
} from "react-admin";
import { useSession } from "next-auth/react";
import { UserRoleInput } from "./shared";
const UserCreate: FC<CreateProps> = () => {
const { data: session } = useSession();
return (
<Create title="Create Users">
<SimpleForm>
<TextInput source="email" />
<TextInput source="name" />
<UserRoleInput session={session} initialValue="NONE" />
<BooleanInput source="isActive" defaultValue={true} />
<TextInput source="createdBy" defaultValue={session?.user?.name} />
</SimpleForm>
</Create>
);
};
export default UserCreate;

View file

@ -0,0 +1,70 @@
"use client";
import { makeStyles } from "@mui/styles";
import {
SimpleForm,
TextInput,
BooleanInput,
DateInput,
Edit,
Toolbar,
SaveButton,
DeleteButton,
useRedirect,
useRecordContext,
} from "react-admin";
import { useSession } from "next-auth/react";
import { UserRoleInput } from "./shared";
const useStyles = makeStyles((_theme: any) => ({
defaultToolbar: {
flex: 1,
display: "flex",
justifyContent: "space-between",
},
}));
const UserEditToolbar = (props: any) => {
const classes = useStyles();
const redirect = useRedirect();
const record = useRecordContext();
const {session} = props;
const shouldDisableDelete = !session || !session.user || session.user.id === record.id;
return (
<Toolbar className={classes.defaultToolbar}>
<SaveButton
label="save"
mutationOptions={{ onSuccess: () => redirect("/users") }}
/>
<DeleteButton disabled={shouldDisableDelete} />
</Toolbar>
);
};
const UserTitle = ({ record }: { record?: any }) => {
let title = "";
if (record) title = record.name ?? record.email;
return <span>User {title}</span>;
};
const UserEdit = () => {
const { data: session } = useSession();
return (
<Edit title={<UserTitle />}>
<SimpleForm toolbar={<UserEditToolbar session={session} />}>
<TextInput disabled source="id" />
<TextInput source="email" />
<TextInput source="name" />
<UserRoleInput session={session} />
<DateInput source="emailVerified" />
<BooleanInput source="isActive" />
<TextInput source="createdBy" />
</SimpleForm>
</Edit>
);
};
export default UserEdit;

View file

@ -0,0 +1,29 @@
"use client";
import {
List,
Datagrid,
ImageField,
DateField,
TextField,
EmailField,
BooleanField,
} from "react-admin";
const UserList = () => (
<List exporter={false}>
<Datagrid rowClick="edit">
<EmailField source="email" />
<DateField source="emailVerified" />
<TextField source="name" />
<ImageField source="avatar" />
<TextField source="userRole" />
<BooleanField source="isActive" />
<DateField source="createdAt" />
<DateField source="updatedAt" />
<TextField source="createdBy" />
</Datagrid>
</List>
);
export default UserList;

View file

@ -0,0 +1,14 @@
"use client";
import UserIcon from "@mui/icons-material/People";
import UserList from "./UserList";
import UserEdit from "./UserEdit";
import UserCreate from "./UserCreate";
// eslint-disable-next-line import/no-anonymous-default-export
export default {
list: UserList,
create: UserCreate,
edit: UserEdit,
icon: UserIcon,
};

View file

@ -0,0 +1,19 @@
"use client";
import { SelectInput, useRecordContext } from "react-admin";
export const UserRoleInput = (props: any) => {
const record = useRecordContext();
return (
<SelectInput
source="userRole"
choices={[
{ id: "NONE", name: "None" },
{ id: "USER", name: "User" },
{ id: "ADMIN", name: "Admin" },
]}
disabled={props.session.user.id === record.id}
{...props}
/>
);
};

View file

@ -0,0 +1,33 @@
"use client";
import {
SimpleForm,
TextInput,
Create,
PasswordInput,
CreateProps,
} from "react-admin";
import { ProviderKindInput } from "./shared";
// import TextField from "@mui/material/TextField";
/* const TwilioCredentialsInput = () => (
<span>
<TextField name="accountSid" label="Account Sid" />
<TextField name="authToken" label="Auth Token" />
</span>
); */
const ProviderCreate = (props: CreateProps) => (
<Create {...props} title="Create Providers">
<SimpleForm>
<ProviderKindInput />
<TextInput source="name" />
<TextInput source="credentials.accountSid" />
<TextInput source="credentials.apiKeySid" />
<PasswordInput source="credentials.apiKeySecret" />
</SimpleForm>
</Create>
);
export default ProviderCreate;

View file

@ -0,0 +1,31 @@
"use client";
import {
SimpleForm,
TextInput,
PasswordInput,
Edit,
EditProps,
} from "react-admin";
import { ProviderKindInput } from "./shared";
const ProviderTitle = ({ record }: { record?: any }) => {
let title = "";
if (record) title = record.name ?? record.email;
return <span>Provider {title}</span>;
};
const ProviderEdit = (props: EditProps) => (
<Edit title={<ProviderTitle />} {...props}>
<SimpleForm>
<TextInput disabled source="id" />
<ProviderKindInput disabled />
<TextInput source="name" />
<TextInput source="credentials.accountSid" />
<TextInput source="credentials.apiKeySid" />
<PasswordInput source="credentials.apiKeySecret" />
</SimpleForm>
</Edit>
);
export default ProviderEdit;

View file

@ -0,0 +1,16 @@
"use client";
import { List, Datagrid, DateField, TextField, ListProps } from "react-admin";
const ProviderList = (props: ListProps) => (
<List {...props} exporter={false}>
<Datagrid rowClick="edit">
<TextField source="kind" />
<TextField source="name" />
<DateField source="createdAt" />
<DateField source="updatedAt" />
</Datagrid>
</List>
);
export default ProviderList;

View file

@ -0,0 +1,14 @@
"use client";
/* eslint-disable import/no-anonymous-default-export */
import ProviderIcon from "@mui/icons-material/Business";
import ProviderList from "./ProviderList";
import ProviderEdit from "./ProviderEdit";
import ProviderCreate from "./ProviderCreate";
export default {
list: ProviderList,
create: ProviderCreate,
edit: ProviderEdit,
icon: ProviderIcon,
};

View file

@ -0,0 +1,11 @@
"use client";
import { SelectInput } from "react-admin";
export const ProviderKindInput = (props: any) => (
<SelectInput
source="kind"
choices={[{ id: "TWILIO", name: "Twilio" }]}
{...props}
/>
);

View file

@ -0,0 +1,43 @@
.voiceWaveWrapper {
width: 100%;
max-height: 50px;
display: flex;
justify-content: center;
}
.hidden {
display: none;
}
.visible {
display: block;
}
.buttonWrapper {
display: flex;
align-items: center;
justify-content: center;
}
.playerWrapper {
display: flex;
justify-content: center;
margin-top: 20px;
}
.recordTime {
align-self: center;
width: 66px;
height: 18px;
margin-top: 10px;
font-family: "sans";
font-style: normal;
font-weight: normal;
font-size: 15px;
line-height: 18px;
color: #000;
}
.content {
width: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
}

View file

@ -0,0 +1,152 @@
"use client";
import { useInput } from "react-admin";
import React, { useState } from "react";
import dynamic from "next/dynamic";
import MicIcon from "@mui/icons-material/Mic";
import StopIcon from "@mui/icons-material/Stop";
import Button from "@mui/material/Button";
import { useTheme } from "@mui/styles"; // makeStyles,
// import AudioPlayer from "material-ui-audio-player";
import { useStopwatch } from "react-timer-hook";
import style from "./MicInput.module.css";
// import type { ReactMicProps } from "react-mic";
const ReactMic = dynamic<any>(
() => {
throw new Error(
"MIC INPUT FEATURE IS DISABLED"
); /* return import("react-mic").then((mod) => mod.ReactMic); */
},
{ ssr: false }
);
const blobToDataUri = (blob: Blob) => {
const reader = new FileReader();
reader.readAsDataURL(blob);
return new Promise((resolve) => {
reader.onloadend = () => {
resolve(reader.result);
};
});
};
const dataUriToObj = (dataUri: string) => {
const [prefix, base64] = dataUri.split(",");
const mime = prefix.slice(5, prefix.indexOf(";"));
const result: any = {};
result[mime] = base64;
return result;
};
const blobToResult = async (blob: Blob) => {
const result = dataUriToObj((await blobToDataUri(blob)) as string);
return result;
};
/* const resultToDataUri = (result: Record<string, any>): string => {
if (!result || !result["audio/webm"]) return "";
const base64 = result["audio/webm"];
const r = `data:audio/webm;base64,${base64}`;
return r;
}; */
const MicInput = (props: any) => {
const { seconds, minutes, hours, start, reset, pause } = useStopwatch();
const theme = useTheme();
const {
field: { onChange }, // value
} = useInput(props);
const [record, setRecorder] = useState({ record: false });
// const decodedValue = resultToDataUri(value);
const startRecording = () => {
setRecorder({ record: true });
reset();
start();
};
const stopRecording = () => {
setRecorder({ record: false });
pause();
};
async function onData(recordedBlob: any) {
console.log({ recordedBlob });
}
async function onStop(recordedBlob: any) {
const result = await blobToResult(recordedBlob.blob);
onChange(result);
}
const isRecording = record.record;
// const canPlay = !isRecording && decodedValue;
const duration = `${hours.toString().padStart(2, "0")}:${minutes
.toString()
.padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
/*
const useStyles = makeStyles(() => ({
volumeIcon: {
display: "none",
},
mainSlider: {
display: "none",
},
}));
*/
return (
<div className="MuiFormControl-marginDense RaFormInput-input-40">
<div className={style.content}>
<div className={style.voiceWaveWrapper}>
<ReactMic
record={record.record}
className={isRecording ? style.visible : style.hidden}
onStop={onStop}
onData={onData}
// @ts-ignore
strokeColor={theme.palette.primary.main}
backgroundColor="white"
mimeType="audio/webm"
visualSetting="frequencyBars"
/>
</div>
<div>{isRecording ? <p>Recording... {duration}</p> : ""}</div>
<div className={style.buttonWrapper}>
{isRecording ? (
<Button variant="contained" color="primary" onClick={stopRecording}>
<StopIcon />
Stop
</Button>
) : (
<Button
variant="contained"
color="primary"
onClick={startRecording}
style={{ marginRight: "20px" }}
>
<MicIcon />
Record
</Button>
)}
</div>
<div className={style.playerWrapper}>
{/* canPlay && (
<AudioPlayer
elevation={0}
src={decodedValue}
variation="secondary"
volume={false}
useStyles={useStyles}
/>
) */}
</div>
</div>
</div>
);
};
export default MicInput;

View file

@ -0,0 +1,54 @@
"use client";
import {
SimpleForm,
Create,
FormDataConsumer,
SelectInput,
BooleanInput,
ReferenceInput,
required,
CreateProps,
} from "react-admin";
import TwilioLanguages from "./twilio-languages";
import {
PromptInput,
VoiceInput,
AvailableNumbersInput,
populateNumber,
} from "./shared";
import MicInput from "./MicInput";
const VoiceLineCreate = (props: CreateProps) => (
<Create {...props} title="Create Voice Line" transform={populateNumber}>
<SimpleForm>
<ReferenceInput
label="Provider"
source="providerId"
reference="voiceProviders"
validate={[required()]}
>
<SelectInput optionText={(p) => `${p.kind}: ${p.name}`} />
</ReferenceInput>
<FormDataConsumer subscription={{ values: true }}>
{AvailableNumbersInput}
</FormDataConsumer>
<SelectInput
source="language"
choices={TwilioLanguages.languages}
validate={[required()]}
/>
<FormDataConsumer subscription={{ values: true }}>
{VoiceInput}
</FormDataConsumer>
<FormDataConsumer subscription={{ values: true }}>
{PromptInput}
</FormDataConsumer>
<BooleanInput source="audioPromptEnabled" />
<MicInput source="promptAudio" />
</SimpleForm>
</Create>
);
export default VoiceLineCreate;

View file

@ -0,0 +1,51 @@
"use client";
import {
SimpleForm,
TextInput,
Edit,
FormDataConsumer,
SelectInput,
BooleanInput,
ReferenceInput,
required,
EditProps,
} from "react-admin";
import TwilioLanguages from "./twilio-languages";
import { VoiceInput, PromptInput } from "./shared";
import MicInput from "./MicInput";
const VoiceLineTitle = ({ record }: { record?: any }) => {
let title = "";
if (record) title = record.name ?? record.email;
return <span>VoiceLine {title}</span>;
};
const VoiceLineEdit = (props: EditProps) => (
<Edit title={<VoiceLineTitle />} {...props}>
<SimpleForm>
<ReferenceInput
disabled
label="Provider"
source="providerId"
reference="providers"
validate={[required()]}
>
<SelectInput optionText={(p) => `${p.kind}: ${p.name}`} />
</ReferenceInput>
<TextInput disabled source="providerLineSid" />
<TextInput disabled source="number" />
<SelectInput source="language" choices={TwilioLanguages.languages} />
<FormDataConsumer subscription={{ values: true }}>
{VoiceInput}
</FormDataConsumer>
<FormDataConsumer subscription={{ values: true }}>
{PromptInput}
</FormDataConsumer>
<BooleanInput source="audioPromptEnabled" />
<MicInput source="promptAudio" />
</SimpleForm>
</Edit>
);
export default VoiceLineEdit;

View file

@ -0,0 +1,32 @@
"use client";
import {
List,
ListProps,
Datagrid,
DateField,
FunctionField,
TextField,
ReferenceField,
} from "react-admin";
const VoiceLineList = (props: ListProps) => (
<List {...props} exporter={false}>
<Datagrid rowClick="edit">
<ReferenceField
label="Provider"
source="providerId"
reference="providers"
>
<FunctionField render={(p: any) => `${p.kind}: ${p.name}`} />
</ReferenceField>
<TextField source="number" />
<TextField source="language" />
<TextField source="voice" />
<DateField source="createdAt" />
<DateField source="updatedAt" />
</Datagrid>
</List>
);
export default VoiceLineList;

View file

@ -0,0 +1,14 @@
"use client";
import VoiceLineIcon from "@mui/icons-material/PhoneCallback";
import VoiceLineList from "./VoiceLineList";
import VoiceLineEdit from "./VoiceLineEdit";
import VoiceLineCreate from "./VoiceLineCreate";
// eslint-disable-next-line import/no-anonymous-default-export
export default {
list: VoiceLineList,
create: VoiceLineCreate,
edit: VoiceLineEdit,
icon: VoiceLineIcon,
};

View file

@ -0,0 +1,149 @@
/* add css module styles here (optional) */
@import url("https://fonts.googleapis.com/css?family=Lato:400,700&display=swap");
.recorder_library_box,
.recorder_library_box * {
box-sizing: border-box;
padding: 0;
margin: 0;
font-family: "Lato", sans-serif;
}
.recorder_library_box .recorder_box {
width: 100%;
margin: 0 auto;
padding: 30px 0;
}
.recorder_library_box .recorder_box_inner {
min-height: 400px;
background: #212121;
border-radius: 0 0 3px 3px;
display: flex;
flex-direction: column;
}
.recorder_library_box .mic_icon {
width: 60px;
display: flex;
height: 60px;
position: fixed;
justify-content: center;
align-items: center;
background: rgb(245, 0, 87);
border-radius: 50%;
bottom: 65px;
right: 20%;
color: #fff;
font-size: 25px;
}
.recorder_library_box .reco_header {
display: flex;
justify-content: space-between;
background: #bd9f61;
align-items: center;
padding: 20px 20px;
color: #fff;
border-radius: 3px 3px 0 0;
}
.recorder_library_box .reco_header .h2 {
font-weight: 400;
}
.recorder_library_box .reco_header .close_icons {
font-size: 20px;
width: 40px;
height: 40px;
border-radius: 50%;
justify-content: center;
align-items: center;
display: flex;
cursor: pointer;
transition: 0.5s ease all;
}
.recorder_library_box .reco_header .close_icons:hover {
background: rgba(123, 118, 106, 0.21);
}
.recorder_library_box .record_section {
position: relative;
flex: 1;
}
.recorder_library_box .record_section .mic_icon {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 20px;
}
.recorder_library_box .record_section .duration_section {
position: absolute;
left: 50%;
transform: translate(-50%);
bottom: 100px;
}
.recorder_library_box .btn_wrapper {
margin: 20px 30px;
}
.recorder_library_box .btn_wrapper .btn {
border: 0;
outline: 0;
padding: 10px 20px;
border-radius: 20px;
background: #185fec;
color: #fff;
cursor: pointer;
border: 1px solid #185fec;
transition: 0.3s ease all;
}
.recorder_library_box .btn_wrapper .btn:hover {
background: #fff;
color: #185fec;
}
.recorder_library_box .btn_wrapper .clear_btn {
background: #fff;
color: #185fec;
margin-left: 15px;
}
.recorder_library_box .btn_wrapper .clear_btn:hover {
background: #185fec;
color: #fff;
}
.recorder_library_box .duration {
text-align: center;
}
.recorder_library_box .recorder_page_box {
min-height: calc(100vh - 128px);
background: #fff;
}
.recorder_library_box .duration * {
color: #fff;
font-size: 60px;
}
.recorder_library_box .duration_section .help {
color: #fff;
}
.recorder_library_box .record_controller {
position: absolute;
left: 50%;
transform: translate(-50%);
bottom: 0px;
padding: 20px 0;
display: flex;
}
.recorder_library_box .record_controller .icons {
width: 50px;
display: flex;
height: 50px;
justify-content: center;
align-items: center;
border-radius: 50%;
color: #fff;
margin-right: 15px;
font-size: 20px;
}
.recorder_library_box .record_controller .stop {
background: #940505;
}
.recorder_library_box .record_controller .pause {
background: #9c6702;
}

View file

@ -0,0 +1,307 @@
"use client";
/* eslint-disable react/display-name */
import React, { useState, useEffect } from "react";
import PlayIcon from "@mui/icons-material/PlayCircleFilled";
import {
TextInput,
SelectInput,
required,
useTranslate,
useNotify,
ReferenceInput,
ReferenceField,
TextField,
} from "react-admin";
import { IconButton, CircularProgress } from "@mui/material";
import absoluteUrl from "../../../_lib/absolute-url";
import TwilioLanguages from "./twilio-languages";
type TTSProvider = (voice: any, language: any, prompt: any) => Promise<void>;
const tts = async (providerId: any): Promise<TTSProvider> => {
const r = await fetch(
`/api/v1/voice/twilio/text-to-speech-token/${providerId}`
);
const { token } = await r.json();
const twilioClient = await import("twilio-client");
return (voice, language, prompt): Promise<void> =>
new Promise((resolve) => {
if (!voice || !language || !prompt) resolve();
const { Device } = twilioClient;
const device = new Device();
const silence = `${absoluteUrl().origin}/static/silence.mp3`;
device.setup(token, {
codecPrefences: ["opus", "pcmu"],
enableRingingState: false,
fakeLocalDTMF: true,
disableAudioContextSounds: true,
sounds: {
disconnect: silence,
incoming: silence,
outgoing: silence,
},
});
device.on("ready", (device: any) => {
device.connect({ language, voice, prompt });
});
device.on("disconnect", () => resolve());
device.on("error", () => resolve());
});
};
export const TextToSpeechButton = ({ form }: any) => {
const { providerId, language, voice, promptText: prompt } = form.formData;
const [loading, setLoading] = useState<boolean>(false);
const [ttsProvider, setTTSProvider] = useState<
undefined | { provider: TTSProvider }
>(undefined);
const [playText, setPlayText] = useState<
undefined | { func: () => Promise<void> }
>(undefined);
useEffect(() => {
(async () => {
if (providerId) {
setLoading(true);
setTTSProvider({ provider: await tts(providerId) });
setLoading(false);
}
})();
}, [providerId]);
useEffect(() => {
(async () => {
setPlayText({
async func() {
setLoading(true);
if (ttsProvider) await ttsProvider.provider(voice, language, prompt);
setLoading(false);
},
});
})();
}, [prompt, language, voice, ttsProvider, ttsProvider?.provider]);
const disabled = !(providerId && prompt?.length >= 2 && voice && language);
/* TODO add this back to IconButtonwhen we know how to extend MUI theme and appease typescript
variant="contained"
*/
return (
<IconButton onClick={playText?.func} disabled={disabled} color="primary">
{!loading && <PlayIcon />}
{loading && <CircularProgress size={20} />}
</IconButton>
);
};
export const PromptInput = (form: any, ...rest: any[]) => (
<TextInput
source="promptText"
multiline
options={{ fullWidth: true }}
InputProps={{ endAdornment: <TextToSpeechButton form={form} /> }}
{...rest}
/>
);
const validateVoice = (_args: any, values: any) => {
if (!values.language) return "validation.language";
if (!values.voice) return "validation.voice";
// @ts-expect-error
const availableVoices = TwilioLanguages.voices[values.language];
const found =
availableVoices.filter((v: any) => v.id === values.voice).length === 1;
if (!found) return "validation.voice";
return undefined;
};
export const VoiceInput = (form: any, ...rest: any[]) => {
// @ts-expect-error
const voice = TwilioLanguages.voices[form.formData.language] || [];
return (
// @ts-expect-error
<SelectInput
source="voice"
choices={voice}
validate={[required(), validateVoice]}
{...rest}
/>
);
};
let noAvailableNumbers = false;
let availableNumbers: any[] = [];
const getAvailableNumbers = async (providerId: string) => {
try {
const r = await fetch(`/api/v1/voice/providers/${providerId}/freeNumbers`);
availableNumbers = await r.json();
noAvailableNumbers = availableNumbers.length === 0;
return availableNumbers;
} catch (error) {
console.error(
`Could not fetch available numbers for provider ${providerId} - ${error}`
);
return [];
}
};
const sidToNumber = (sid: any) =>
availableNumbers.filter(({ id }) => id === sid).map(({ name }) => name)[0];
export const populateNumber = (data: any) => ({
...data,
number: sidToNumber(data.providerLineSid),
});
const hasNumbers = (
_args: any,
_value: any,
_values: any,
_translate: any,
..._props: any[]
) => {
if (noAvailableNumbers) return "validation.noAvailableNumbers";
return undefined;
};
export const AvailableNumbersInput = (form: any, ...rest: any[]) => {
const {
// @ts-expect-error: non-existent property
meta: { touched, error } = {},
// @ts-expect-error: non-existent property
input: { ...inputProps },
...props
} = rest;
const translate = useTranslate();
const notify = useNotify();
const [loading, setLoading] = useState(false);
const [choices, setChoices] = useState({});
// @ts-expect-error: Invalid return type
useEffect(async () => {
if (form && form.formData && form.formData.providerId) {
setLoading(true);
const choices = await getAvailableNumbers(form.formData.providerId);
setChoices({
choices,
helperText: noAvailableNumbers
? translate("validation.noAvailableNumbers")
: "",
});
if (noAvailableNumbers)
notify("validation.noAvailableNumbers", { type: "error" });
setLoading(false);
}
}, [form, notify, translate]);
return (
<>
<SelectInput
label="Number"
source="providerLineSid"
// @ts-expect-error: non-existent property
choices={choices.choices}
disabled={loading}
validate={[hasNumbers, required()]}
// @ts-expect-error: non-existent property
error={Boolean(touched && error) || Boolean(choices.helperText)}
// @ts-expect-error: non-existent property
helperText={choices.helperText}
{...inputProps}
{...props}
/>
{loading && <CircularProgress />}
</>
);
};
/*
const voiceLineName = voiceLine => {
return voiceLine.number
}
const getVoiceLineChoices = async ():Promise<any[]> => {
try {
const r = await fetch(`/api/v1/voice/voice-line`);
const lines = await r.json();
if(lines.data?.length > 0) {
return lines.data.map(voiceLine => ({"id": voiceLine.id, "name": voiceLineName(voiceLine)}))
}
return [];
} catch (error) {
console.error(
`Could not fetch voice lines error: ${error}`
);
return [];
}
}
export const AsyncSelectInput = (choiceLoader: () => Promise<any[]>, label, source, translationEmpty,) => (form, ...rest) => {
const {
meta: { touched, error } = {},
input: { ...inputProps },
...props
} = rest;
const translate = useTranslate();
const notify = useNotify();
const [loading, setLoading] = useState(false);
const [choices, setChoices] = useState({choices: []});
useEffect(() => {
(async () => {
setLoading(true);
//const items = await choiceLoader()
const items = [{"id": "testing", "name": "OMG"}]
setChoices({
choices: items,
helperText: items.length === 0
? translate(translationEmpty)
: "",
});
if (items.length === 0) notify(translationEmpty, "error");
setLoading(false);
})()}, [form && form.formData ? form.formData.providerId : undefined]);
const isNotEmpty = () => {
if (choices.choices.length === 0) return translationEmpty;
return undefined;
};
return (
<>
{choices.choices.length > 0 &&
<SelectInput
label={label}
source={source}
choices={choices.choices}
disabled={loading}
validate={[isNotEmpty, required()]}
error={Boolean(touched && error) || Boolean(choices.helperText)}
helperText={choices.helperText}
{...inputProps}
{...props}
/>}
{loading && <CircularProgress />}
</>
)
}
export const VoiceLineSelectInput = AsyncSelectInput(getVoiceLineChoices, "Voice Line", "backendId", "validation.noVoiceLines" )
*/
export const VoiceLineSelectInput = (source: string) => () =>
(
<ReferenceInput
label="Voice Line"
source={source}
reference="voiceLines"
validate={[required()]}
>
<SelectInput optionText="number" />
</ReferenceInput>
);
export const VoiceLineField = (source: string) => () =>
(
<ReferenceField label="Voice Line" source={source} reference="voiceLines">
<TextField source="number" />
</ReferenceField>
);

View file

@ -0,0 +1,65 @@
const languages = {
languages: [
{ id: "arb", name: "Arabic" },
{ id: "cy-GB", name: "Welsh" },
{ id: "da-DK", name: "Danish" },
{ id: "de-DE", name: "German" },
{ id: "en-US", name: "English (US)" },
{ id: "en-AU", name: "English (Australian)" },
{ id: "en-GB", name: "English (British)" },
{ id: "en-GB-WLS", name: "English (Welsh)" },
{ id: "en-IN", name: "English (Indian)" },
{ id: "es-ES", name: "Spanish (Castilian)" },
{ id: "es-MX", name: "Spanish (Mexico)" },
{ id: "es-US", name: "Spanish (Latin American)" },
{ id: "fr-CA", name: "French (Canadian)" },
{ id: "fr-FR", name: "French" },
{ id: "hi-IN", name: "Hindi" },
{ id: "is-IS", name: "Icelandic" },
{ id: "it-IT", name: "Italian" },
{ id: "ja-JP", name: "Japanese" },
{ id: "ko-KR", name: "Korean" },
{ id: "nb-NO", name: "Norwegian" },
{ id: "nl-NL", name: "Dutch" },
{ id: "pl-PL", name: "Polish" },
{ id: "pt-BR", name: "Portuguese (Brazilian)" },
{ id: "pt-PT", name: "Portuguese (European)" },
{ id: "ro-RO", name: "Romanian" },
{ id: "ru-RU", name: "Russian" },
{ id: "sv-SE", name: "Swedish" },
{ id: "tr-TR", name: "Turkish" },
{ id: "zh-CN", name: "Chinese (Mandarin)" },
],
voices: {
arb: [{ id: "Polly.Zeina", name: "Zeina" }],
"cy-GB": [{ id: "Polly.Gwyneth", name: "Gwyneth" }],
"da-DK": [{ id: "Polly.Naja", name: "Naja" }],
"de-DE": [{ id: "Polly.Marlene", name: "Marlene" }],
"en-US": [{ id: "Polly.Salli", name: "Salli" }],
"en-AU": [{ id: "Polly.Nicole", name: "Nicole" }],
"en-GB": [{ id: "Polly.Amy", name: "Amy" }],
"en-GB-WLS": [{ id: "Polly.Geraint", name: "Geraint" }],
"en-IN": [{ id: "Polly.Aditi", name: "Aditi" }],
"es-ES": [{ id: "Polly.Conchita", name: "Conchita" }],
"es-MX": [{ id: "Polly.Mia", name: "Mia" }],
"es-US": [{ id: "Polly.Penelope", name: "Penelope" }],
"fr-CA": [{ id: "Polly.Chantal", name: "Chantal" }],
"fr-FR": [{ id: "Polly.Celine", name: "Celine" }],
"hi-IN": [{ id: "Polly.Aditi", name: "Aditi" }],
"is-IS": [{ id: "Polly.Dora", name: "Dora" }],
"it-IT": [{ id: "Polly.Carla", name: "Carla" }],
"ja-JP": [{ id: "Polly.Mizuki", name: "Mizuki" }],
"ko-KR": [{ id: "Polly.Seoyeon", name: "Seoyeon" }],
"nb-NO": [{ id: "Polly.Liv", name: "Liv" }],
"nl-NL": [{ id: "Polly.Lotte", name: "Lotte" }],
"pl-PL": [{ id: "Polly.Ewa", name: "Ewa" }],
"pt-BR": [{ id: "Polly.Vitoria", name: "Vitoria" }],
"pt-PT": [{ id: "Polly.Ines", name: "Ines" }],
"ro-RO": [{ id: "Polly.Carmen", name: "Carmen" }],
"ru-RU": [{ id: "Polly.Tatyana", name: "Tatyana" }],
"sv-SE": [{ id: "Polly.Astrid", name: "Astrid" }],
"tr-TR": [{ id: "Polly.Filiz", name: "Filiz" }],
"zh-CN": [{ id: "Polly.Zhiyu", name: "Zhiyu" }],
},
};
export default languages;

View file

@ -0,0 +1,53 @@
"use client";
import {
SimpleForm,
FormDataConsumer,
TextInput,
Create,
ArrayInput,
SimpleFormIterator,
regex,
required,
CreateProps,
} from "react-admin";
import { BackendTypeInput, BackendIdInput, HttpMethodInput } from "./shared";
/*
<ReferenceInput
label="Voice Line"
source="voiceLineId"
reference="voiceLines"
validate={[required()]}
>
<SelectInput optionText="number" />
</ReferenceInput>
*/
const WebhookCreate = (props: CreateProps) => (
<Create {...props} title="Create Webhooks">
<SimpleForm>
<TextInput source="name" validate={[required()]} />
<BackendTypeInput />
<FormDataConsumer subscription={{ values: true }}>
{BackendIdInput}
</FormDataConsumer>
<TextInput
source="endpointUrl"
validate={[required(), regex(/^https?:\/\/[^/]+/, "validation.url")]}
/>
<HttpMethodInput />
<ArrayInput source="headers">
<SimpleFormIterator>
<TextInput
source="header"
validate={[required(), regex(/^[\w-]+$/, "validation.headerName")]}
/>
<TextInput source="value" validate={[required()]} />
</SimpleFormIterator>
</ArrayInput>
</SimpleForm>
</Create>
);
export default WebhookCreate;

View file

@ -0,0 +1,48 @@
"use client";
import {
SimpleForm,
TextInput,
Edit,
ArrayInput,
SimpleFormIterator,
regex,
required,
EditProps,
FormDataConsumer,
} from "react-admin";
import { BackendTypeInput, BackendIdInput, HttpMethodInput } from "./shared";
const WebhookTitle = ({ record }: any) => {
let title = "";
if (record) title = record.name ?? record.email;
return <span>Webhook {title}</span>;
};
const WebhookEdit = (props: EditProps) => (
<Edit title={<WebhookTitle />} {...props}>
<SimpleForm>
<TextInput source="name" validate={[required()]} />
<BackendTypeInput />
<FormDataConsumer subscription={{ values: true }}>
{BackendIdInput}
</FormDataConsumer>
<TextInput
source="endpointUrl"
validate={[required(), regex(/^https?:\/\/[^/]+/, "validation.url")]}
/>
<HttpMethodInput />
<ArrayInput source="headers">
<SimpleFormIterator>
<TextInput
source="header"
validate={[required(), regex(/^[\w-]+$/, "validation.headerName")]}
/>
<TextInput source="value" validate={[required()]} />
</SimpleFormIterator>
</ArrayInput>
</SimpleForm>
</Edit>
);
export default WebhookEdit;

View file

@ -0,0 +1,18 @@
"use client";
import { List, Datagrid, DateField, TextField, ListProps } from "react-admin";
import { BackendIdField } from "./shared";
const WebhookList = (props: ListProps) => (
<List {...props} exporter={false}>
<Datagrid rowClick="edit">
<TextField source="name" />
<TextField source="backendType" />
<BackendIdField source="backendId" />
<DateField source="createdAt" />
<DateField source="updatedAt" />
</Datagrid>
</List>
);
export default WebhookList;

View file

@ -0,0 +1,14 @@
"use client";
import WebhookIcon from "@mui/icons-material/Send";
import WebhookList from "./WebhookList";
import WebhookEdit from "./WebhookEdit";
import WebhookCreate from "./WebhookCreate";
// eslint-disable-next-line import/no-anonymous-default-export
export default {
list: WebhookList,
create: WebhookCreate,
edit: WebhookEdit,
icon: WebhookIcon,
};

View file

@ -0,0 +1,72 @@
"use client";
import { SelectInput, required } from "react-admin";
import {
VoiceLineField,
VoiceLineSelectInput,
} from "../voice/voicelines/shared";
import {
WhatsAppBotField,
WhatsAppBotSelectInput,
} from "../whatsapp/bots/shared";
import { SignalBotField, SignalBotSelectInput } from "../signal/bots/shared";
const httpChoices = [
{ id: "post", name: "POST" },
{ id: "put", name: "PUT" },
];
export const HttpMethodInput = (props: any) => (
<SelectInput
source="httpMethod"
choices={httpChoices}
validate={[required()]}
initialValue="post"
{...props}
/>
);
const backendChoices = [
{ id: "signal", name: "Signal" },
{ id: "whatsapp", name: "WhatsApp" },
{ id: "voice", name: "Voice" },
];
const backendInputComponents = {
whatsapp: WhatsAppBotSelectInput("backendId"),
signal: SignalBotSelectInput("backendId"),
voice: VoiceLineSelectInput("backendId"),
};
const backendFieldComponents = {
whatsapp: WhatsAppBotField("backendId"),
signal: SignalBotField("backendId"),
voice: VoiceLineField("backendId"),
};
export const BackendTypeInput = (props: any) => (
<SelectInput
source="backendType"
choices={backendChoices}
validate={[required()]}
{...props}
/>
);
export const BackendIdInput = (form: any, ...rest: any[]) => {
const Component = form.formData.backendType
? // @ts-expect-error
backendInputComponents[form.formData.backendType]
: false;
return <>{Component && <Component form={form} {...rest} />}</>;
};
export const BackendIdField = (form: any, ...rest: any[]) => {
console.log(form);
const Component = form.record.backendType
? // @ts-expect-error
backendFieldComponents[form.record.backendType]
: false;
return <>{Component && <Component form={form} {...rest} />}</>;
};

View file

@ -0,0 +1,13 @@
"use client";
import { List, Datagrid, TextField } from "react-admin";
const WhatsappAttachmentList = (props: any) => (
<List {...props} exporter={false}>
<Datagrid rowClick="show">
<TextField source="id" />
</Datagrid>
</List>
);
export default WhatsappAttachmentList;

View file

@ -0,0 +1,13 @@
"use client";
import { Show, ShowProps, SimpleShowLayout, TextField } from "react-admin";
const WhatsappAttachmentShow = (props: ShowProps) => (
<Show {...props} title="Whatsapp Attachment">
<SimpleShowLayout>
<TextField source="id" />
</SimpleShowLayout>
</Show>
);
export default WhatsappAttachmentShow;

View file

@ -0,0 +1,12 @@
"use client";
import WhatsappAttachmentIcon from "@mui/icons-material/AttachFile";
import WhatsappAttachmentList from "./WhatsappAttachmentList";
import WhatsappAttachmentShow from "./WhatsappAttachmentShow";
// eslint-disable-next-line import/no-anonymous-default-export
export default {
list: WhatsappAttachmentList,
show: WhatsappAttachmentShow,
icon: WhatsappAttachmentIcon,
};

View file

@ -0,0 +1,40 @@
"use client";
// import dynamic from "next/dynamic";
import { SimpleForm, Create, TextInput, required } from "react-admin";
import { useSession } from "next-auth/react";
import { validateE164Number } from "../../../_lib/phone-numbers";
const WhatsappBotCreate = (props: any) => {
// const MuiPhoneNumber = dynamic(() => import("material-ui-phone-number"), {
// ssr: false,
// });
const { data: session } = useSession();
return (
<Create {...props} title="Create Whatsapp Bot" redirect="show">
<SimpleForm>
<TextInput
source="userId"
defaultValue={
// @ts-expect-error: non-existent property
session.user.id
}
/>
<TextInput
source="phoneNumber"
validate={[validateE164Number, required()]}
/>
{/* <MuiPhoneNumber
defaultCountry={"us"}
fullWidth
onChange={(e: any) => setFieldValue("phoneNumber", e)}
/> */}
<TextInput source="description" />
</SimpleForm>
</Create>
);
};
export default WhatsappBotCreate;

View file

@ -0,0 +1,14 @@
"use client";
import { SimpleForm, Edit, TextInput, required, EditProps } from "react-admin";
const WhatsappBotEdit = (props: EditProps) => (
<Edit {...props} title="Edit Bot">
<SimpleForm>
<TextInput source="phoneNumber" validate={[required()]} />
<TextInput source="description" />
</SimpleForm>
</Edit>
);
export default WhatsappBotEdit;

View file

@ -0,0 +1,24 @@
"use client";
import {
List,
Datagrid,
DateField,
TextField,
BooleanField,
} from "react-admin";
const WhatsappBotList = (props: any) => (
<List {...props} exporter={false}>
<Datagrid rowClick="show">
<TextField source="phoneNumber" />
<TextField source="description" />
<BooleanField source="isVerified" />
<DateField source="createdAt" />
<DateField source="updatedAt" />
<TextField source="createdBy" />
</Datagrid>
</List>
);
export default WhatsappBotList;

View file

@ -0,0 +1,177 @@
"use client";
import React, { useEffect, useState } from "react";
import {
Card,
Typography,
Grid,
Button,
TextField as MaterialTextField,
IconButton,
} from "@mui/material";
import {
Show,
SimpleShowLayout,
TextField,
ShowProps,
useGetOne,
useRefresh,
BooleanField,
} from "react-admin";
import QRCode from "react-qr-code";
import useSWR from "swr";
import RefreshIcon from "@mui/icons-material/Refresh";
const Sidebar = ({ record }: any) => {
const [receivedMessages, setReceivedMessages] = useState([]);
const [phoneNumber, setPhoneNumber] = useState("");
const handlePhoneNumberChange = (event: any) => {
setPhoneNumber(event.target.value);
};
const [message, setMessage] = useState("");
const handleMessageChange = (event: any) => {
setMessage(event.target.value);
};
const sendMessage = async (phoneNumber: string, message: string) => {
await fetch(`/api/v1/whatsapp/bots/${record.token}/send`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ phoneNumber, message }),
});
};
const receiveMessages = async () => {
const result = await fetch(`/api/v1/whatsapp/bots/${record.token}/receive`);
const msgs = await result.json();
console.log(msgs);
setReceivedMessages(msgs);
};
return (
<Card style={{ width: "33%", marginLeft: 20, padding: 14 }}>
<Grid container direction="column" spacing={2}>
<Grid item>
<Typography variant="h6">Send message</Typography>
</Grid>
<Grid item>
<MaterialTextField
variant="outlined"
label="Phone number"
fullWidth
size="small"
value={phoneNumber}
onChange={handlePhoneNumberChange}
/>
</Grid>
<Grid item>
<MaterialTextField
variant="outlined"
label="Message"
multiline
rows={3}
fullWidth
size="small"
value={message}
onChange={handleMessageChange}
/>
</Grid>
<Grid item container direction="row-reverse">
<Button
variant="contained"
color="primary"
onClick={() => sendMessage(phoneNumber, message)}
>
Send
</Button>
</Grid>
<Grid item container direction="row">
<Grid item>
<Typography variant="h6">Receive messages</Typography>
</Grid>
<Grid item>
<IconButton
onClick={receiveMessages}
color="primary"
style={{ marginTop: -12 }}
>
<RefreshIcon />
</IconButton>
</Grid>
</Grid>
{receivedMessages.map((receivedMessage: any, index: number) => (
<Grid key={index} item container direction="column" spacing={1}>
<Grid item style={{ fontWeight: "bold", color: "#999" }}>
{receivedMessage.key.remoteJid.replace("@s.whatsapp.net", "")}
</Grid>
<Grid item style={{ borderBottom: "1px solid #999" }}>
{receivedMessage.message.conversation}
</Grid>
</Grid>
))}
</Grid>
</Card>
);
};
const WhatsappBotShow = (props: ShowProps) => {
const refresh = useRefresh();
const { data } = useGetOne("whatsappBots", {id: props.id});
const { data: registerData, error: registerError } = useSWR(
data && !data?.isVerified
? `/api/v1/whatsapp/bots/${props.id}/register`
: undefined,
{ refreshInterval: 59000 }
);
const unverifyBot = async () => {
await fetch(`/api/v1/whatsapp/bots/${props.id}/unverify`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ verified: false }),
});
};
console.log({ registerData, registerError });
useEffect(() => {
if (data && !data?.isVerified) {
const interval = setInterval(() => {
refresh();
}, 10000);
return () => clearInterval(interval);
}
return undefined;
}, [refresh, data]);
return (
<Show {...props} title="Bot" aside={<Sidebar />}>
<SimpleShowLayout>
<TextField source="phoneNumber" />
<BooleanField source="isVerified" />
<Button
color="primary"
size="small"
style={{ color: "black", backgroundColor: "#ddd" }}
onClick={async () => unverifyBot()}
>
Unverify
</Button>
<TextField source="description" />
<TextField source="token" />
{!data?.isVerified && data?.qrCode && data?.qrCode !== "" && (
<QRCode value={data.qrCode} />
)}
</SimpleShowLayout>
</Show>
);
};
export default WhatsappBotShow;

View file

@ -0,0 +1,16 @@
"use client";
import WhatsappBotIcon from "@mui/icons-material/WhatsApp";
import WhatsappBotList from "./WhatsappBotList";
import WhatsappBotEdit from "./WhatsappBotEdit";
import WhatsappBotCreate from "./WhatsappBotCreate";
import WhatsappBotShow from "./WhatsappBotShow";
// eslint-disable-next-line import/no-anonymous-default-export
export default {
list: WhatsappBotList,
create: WhatsappBotCreate,
edit: WhatsappBotEdit,
show: WhatsappBotShow,
icon: WhatsappBotIcon,
};

View file

@ -0,0 +1,33 @@
"use client";
/* eslint-disable react/display-name */
import {
SelectInput,
required,
ReferenceInput,
ReferenceField,
TextField,
} from "react-admin";
export const WhatsAppBotSelectInput = (source: string) => () =>
(
<ReferenceInput
label="WhatsApp Bot"
reference="whatsappBots"
source={source}
validate={[required()]}
>
<SelectInput optionText="phoneNumber" />
</ReferenceInput>
);
export const WhatsAppBotField = (source: string) => () =>
(
<ReferenceField
label="WhatsApp Bot"
reference="whatsappBots"
source={source}
>
<TextField source="phoneNumber" />
</ReferenceField>
);

View file

@ -0,0 +1,25 @@
"use client";
import {
List,
ListProps,
Datagrid,
DateField,
TextField,
BooleanField,
} from "react-admin";
const WhatsappMessageList = (props: ListProps) => (
<List {...props} exporter={false}>
<Datagrid rowClick="show">
<TextField source="phoneNumber" />
<TextField source="description" />
<BooleanField source="isVerified" />
<DateField source="createdAt" />
<DateField source="updatedAt" />
<TextField source="createdBy" />
</Datagrid>
</List>
);
export default WhatsappMessageList;

View file

@ -0,0 +1,31 @@
"use client";
import {
Show,
ShowProps,
SimpleShowLayout,
TextField,
ReferenceManyField,
Datagrid,
} from "react-admin";
const WhatsappMessageShow = (props: ShowProps) => (
<Show {...props} title="Whatsapp Message">
<SimpleShowLayout>
<TextField source="waMessage" />
<TextField source="createdAt" />
<ReferenceManyField
label="Attachments"
reference="whatsappAttachments"
target="whatsappMessageId"
>
<Datagrid rowClick="show">
<TextField source="id" />
<TextField source="createdAt" />
</Datagrid>
</ReferenceManyField>
</SimpleShowLayout>
</Show>
);
export default WhatsappMessageShow;

View file

@ -0,0 +1,12 @@
"use client";
import WhatsappMessageIcon from "@mui/icons-material/Message";
import WhatsappMessageList from "./WhatsappMessageList";
import WhatsappMessageShow from "./WhatsappMessageShow";
// eslint-disable-next-line import/no-anonymous-default-export
export default {
list: WhatsappMessageList,
show: WhatsappMessageShow,
icon: WhatsappMessageIcon,
};