remixed v0
This commit is contained in:
51
app/features/core/components/alert.tsx
Normal file
51
app/features/core/components/alert.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import type { FunctionComponent, ReactChild } from "react";
|
||||
|
||||
type AlertVariant = "error" | "success" | "info" | "warning";
|
||||
|
||||
type AlertVariantProps = {
|
||||
backgroundColor: string;
|
||||
titleTextColor: string;
|
||||
messageTextColor: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
title: ReactChild;
|
||||
message: ReactChild;
|
||||
variant: AlertVariant;
|
||||
};
|
||||
|
||||
const ALERT_VARIANTS: Record<AlertVariant, AlertVariantProps> = {
|
||||
error: {
|
||||
backgroundColor: "bg-red-50",
|
||||
titleTextColor: "text-red-800",
|
||||
messageTextColor: "text-red-700",
|
||||
},
|
||||
success: {
|
||||
backgroundColor: "bg-green-50",
|
||||
titleTextColor: "text-green-800",
|
||||
messageTextColor: "text-green-700",
|
||||
},
|
||||
info: {
|
||||
backgroundColor: "bg-primary-50",
|
||||
titleTextColor: "text-primary-800",
|
||||
messageTextColor: "text-primary-700",
|
||||
},
|
||||
warning: {
|
||||
backgroundColor: "bg-yellow-50",
|
||||
titleTextColor: "text-yellow-800",
|
||||
messageTextColor: "text-yellow-700",
|
||||
},
|
||||
};
|
||||
|
||||
const Alert: FunctionComponent<Props> = ({ title, message, variant }) => {
|
||||
const variantProperties = ALERT_VARIANTS[variant];
|
||||
|
||||
return (
|
||||
<div className={`rounded-md p-4 ${variantProperties.backgroundColor}`}>
|
||||
<h3 className={`text-sm leading-5 font-medium ${variantProperties.titleTextColor}`}>{title}</h3>
|
||||
<div className={`mt-2 text-sm leading-5 ${variantProperties.messageTextColor}`}>{message}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Alert;
|
26
app/features/core/components/button.tsx
Normal file
26
app/features/core/components/button.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import type { ButtonHTMLAttributes, FunctionComponent } from "react";
|
||||
import { useTransition } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
|
||||
type Props = ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
const Button: FunctionComponent<Props> = ({ children, ...props }) => {
|
||||
const transition = useTransition();
|
||||
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
"w-full flex justify-center py-2 px-4 border border-transparent text-base font-medium rounded-md text-white focus:outline-none focus:border-primary-700 focus:shadow-outline-primary transition duration-150 ease-in-out",
|
||||
{
|
||||
"bg-primary-400 cursor-not-allowed": transition.state === "submitting",
|
||||
"bg-primary-600 hover:bg-primary-700": transition.state !== "submitting",
|
||||
},
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default Button;
|
44
app/features/core/components/footer.tsx
Normal file
44
app/features/core/components/footer.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { NavLink } from "@remix-run/react";
|
||||
import { IoCall, IoKeypad, IoChatbubbles, IoSettings } from "react-icons/io5";
|
||||
import clsx from "clsx";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer
|
||||
className="grid grid-cols-4 bg-[#F7F7F7] border-t border-gray-400 border-opacity-25 py-3 z-10"
|
||||
style={{ flex: "0 0 50px" }}
|
||||
>
|
||||
<FooterLink label="Calls" path="/calls" icon={<IoCall className="w-6 h-6" />} />
|
||||
<FooterLink label="Keypad" path="/keypad" icon={<IoKeypad className="w-6 h-6" />} />
|
||||
<FooterLink label="Messages" path="/messages" icon={<IoChatbubbles className="w-6 h-6" />} />
|
||||
<FooterLink label="Settings" path="/settings" icon={<IoSettings className="w-6 h-6" />} />
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
type FooterLinkProps = {
|
||||
path: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
};
|
||||
|
||||
function FooterLink({ path, label, icon }: FooterLinkProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-around h-full">
|
||||
<NavLink
|
||||
to={path}
|
||||
prefetch="none"
|
||||
className={({ isActive }) =>
|
||||
clsx("flex flex-col items-center", {
|
||||
"text-primary-500": isActive,
|
||||
"text-[#959595]": !isActive,
|
||||
})
|
||||
}
|
||||
>
|
||||
{icon}
|
||||
<span className="text-xs">{label}</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
);
|
||||
}
|
35
app/features/core/components/inactive-subscription.tsx
Normal file
35
app/features/core/components/inactive-subscription.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { IoSettings, IoAlertCircleOutline } from "react-icons/io5";
|
||||
|
||||
export default function InactiveSubscription() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="flex items-end justify-center min-h-full overflow-y-hidden pt-4 px-4 pb-4 text-center md:block md:p-0 z-10">
|
||||
<span className="hidden md:inline-block md:align-middle md:h-screen">​</span>
|
||||
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all md:my-8 md:align-middle md:max-w-lg md:w-full md:p-6">
|
||||
<div className="text-center my-auto p-4">
|
||||
<IoAlertCircleOutline className="mx-auto h-12 w-12 text-gray-400" aria-hidden="true" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">
|
||||
You don't have any active subscription
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 max-w-sm mx-auto break-normal whitespace-normal">
|
||||
You need an active subscription to use this feature.
|
||||
<br />
|
||||
Head over to your settings to pick up a plan.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-500 focus:outline-none focus:ring-2 focus:ring-offset-2"
|
||||
onClick={() => navigate("/settings/billing")}
|
||||
>
|
||||
<IoSettings className="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
|
||||
Choose a plan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
46
app/features/core/components/labeled-text-field.tsx
Normal file
46
app/features/core/components/labeled-text-field.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import type { FunctionComponent, InputHTMLAttributes, ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
label: ReactNode;
|
||||
sideLabel?: ReactNode;
|
||||
type?: "text" | "password" | "email";
|
||||
error?: string;
|
||||
} & InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
const LabeledTextField: FunctionComponent<Props> = ({ name, label, sideLabel, type = "text", error, ...props }) => {
|
||||
const hasSideLabel = !!sideLabel;
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<label
|
||||
htmlFor={name}
|
||||
className={clsx("text-sm font-medium leading-5 text-gray-700", {
|
||||
block: !hasSideLabel,
|
||||
"flex justify-between": hasSideLabel,
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
{sideLabel ?? null}
|
||||
</label>
|
||||
<div className="mt-1 rounded-md shadow-sm">
|
||||
<input
|
||||
id={name}
|
||||
name={name}
|
||||
type={type}
|
||||
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
|
||||
required
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<div role="alert" className="text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LabeledTextField;
|
13
app/features/core/components/logo.tsx
Normal file
13
app/features/core/components/logo.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Logo: FunctionComponent<Props> = ({ className }) => (
|
||||
<div className={className}>
|
||||
<img src="/shellphone.png" alt="app logo" />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Logo;
|
25
app/features/core/components/missing-twilio-credentials.tsx
Normal file
25
app/features/core/components/missing-twilio-credentials.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { Link } from "@remix-run/react";
|
||||
import { IoSettings, IoAlertCircleOutline } from "react-icons/io5";
|
||||
|
||||
export default function MissingTwilioCredentials() {
|
||||
return (
|
||||
<div className="text-center my-auto">
|
||||
<IoAlertCircleOutline className="mx-auto h-12 w-12 text-gray-400" aria-hidden="true" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">You haven't set up any phone number yet</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Head over to your settings
|
||||
<br />
|
||||
to set up your phone number.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
to="/settings/account"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-500 focus:outline-none focus:ring-2 focus:ring-offset-2"
|
||||
>
|
||||
<IoSettings className="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
|
||||
Set up my phone number
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
61
app/features/core/components/modal.tsx
Normal file
61
app/features/core/components/modal.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import type { FunctionComponent, MutableRefObject, PropsWithChildren } from "react";
|
||||
import { Fragment } from "react";
|
||||
import { Transition, Dialog } from "@headlessui/react";
|
||||
|
||||
type Props = {
|
||||
initialFocus?: MutableRefObject<HTMLElement | null> | undefined;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const Modal: FunctionComponent<PropsWithChildren<Props>> = ({ children, initialFocus, isOpen, onClose }) => {
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={Fragment}>
|
||||
<Dialog
|
||||
className="fixed z-30 inset-0 overflow-y-auto"
|
||||
initialFocus={initialFocus}
|
||||
onClose={onClose}
|
||||
open={isOpen}
|
||||
static
|
||||
>
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center md:block md:p-0">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
{/* This element is to trick the browser into centering the modal contents. */}
|
||||
<span className="hidden md:inline-block md:align-middle md:h-screen">​</span>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 md:translate-y-0 md:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 md:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 md:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 md:translate-y-0 md:scale-95"
|
||||
>
|
||||
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all md:my-8 md:align-middle md:max-w-lg md:w-full md:p-6">
|
||||
{children}
|
||||
</div>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export const ModalTitle: FunctionComponent<PropsWithChildren<{}>> = ({ children }) => (
|
||||
<Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900">
|
||||
{children}
|
||||
</Dialog.Title>
|
||||
);
|
||||
|
||||
export default Modal;
|
17
app/features/core/components/page-title.tsx
Normal file
17
app/features/core/components/page-title.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
const PageTitle: FunctionComponent<Props> = ({ className, title }) => {
|
||||
return (
|
||||
<div className={clsx(className, "flex flex-col space-y-6 p-3")}>
|
||||
<h2 className="text-3xl font-bold">{title}</h2>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageTitle;
|
23
app/features/core/components/phone-init-loader.tsx
Normal file
23
app/features/core/components/phone-init-loader.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
export default function PhoneInitLoader() {
|
||||
return (
|
||||
<div className="px-4 my-auto text-center space-y-2">
|
||||
<svg
|
||||
className="animate-spin mx-auto h-5 w-5 text-primary-700"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<p>We're finalizing your 🐚phone initialization.</p>
|
||||
<p>
|
||||
You don't have to refresh this page, we will do it automatically for you when your phone is ready.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
68
app/features/core/components/select.tsx
Normal file
68
app/features/core/components/select.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { Fragment } from "react";
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
import { HiCheck as CheckIcon, HiSelector as SelectorIcon } from "react-icons/hi";
|
||||
import clsx from "clsx";
|
||||
|
||||
type Option = { name: string; value: string };
|
||||
|
||||
type Props = {
|
||||
options: Option[];
|
||||
onChange: (selectedValue: Option) => void;
|
||||
value: Option;
|
||||
};
|
||||
|
||||
export default function Select({ options, onChange, value }: Props) {
|
||||
return (
|
||||
<Listbox value={value} onChange={onChange}>
|
||||
<div className="relative mt-1">
|
||||
<Listbox.Button className="relative w-full py-2 pl-3 pr-10 text-left bg-white rounded-lg shadow-md cursor-default focus:outline-none focus-visible:ring-2 focus-visible:ring-opacity-75 focus-visible:ring-white focus-visible:ring-offset-orange-300 focus-visible:ring-offset-2 focus-visible:border-indigo-500 sm:text-sm">
|
||||
<span className="block truncate">{value.name}</span>
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<SelectorIcon className="w-5 h-5 text-gray-400" aria-hidden="true" />
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute w-full py-1 mt-1 overflow-auto text-base bg-white rounded-md shadow-lg max-h-60 ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
|
||||
{options.map((option, index) => (
|
||||
<Listbox.Option
|
||||
key={`option-${option}-${index}`}
|
||||
className={({ active }) =>
|
||||
clsx(
|
||||
"cursor-default select-none relative py-2 pl-10 pr-4",
|
||||
active ? "text-amber-900 bg-amber-100" : "text-gray-900",
|
||||
)
|
||||
}
|
||||
value={option}
|
||||
>
|
||||
{({ selected, active }) => (
|
||||
<>
|
||||
<span
|
||||
className={clsx("block truncate", selected ? "font-medium" : "font-normal")}
|
||||
>
|
||||
{option.name}
|
||||
</span>
|
||||
{selected ? (
|
||||
<span
|
||||
className={clsx(
|
||||
"absolute inset-y-0 left-0 flex items-center pl-3",
|
||||
active ? "text-amber-600" : "text-amber-600",
|
||||
)}
|
||||
>
|
||||
<CheckIcon className="w-5 h-5" aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
);
|
||||
}
|
15
app/features/core/components/spinner.css
Normal file
15
app/features/core/components/spinner.css
Normal file
@ -0,0 +1,15 @@
|
||||
.ring {
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 3px solid rgba(0, 0, 0, 0.15);
|
||||
border-radius: 50%;
|
||||
border-top-color: currentColor;
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
15
app/features/core/components/spinner.tsx
Normal file
15
app/features/core/components/spinner.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import type { LinksFunction } from "@remix-run/node";
|
||||
|
||||
import styles from "./spinner.css";
|
||||
|
||||
export const links: LinksFunction = () => [
|
||||
{ rel: "stylesheet", href: styles },
|
||||
];
|
||||
|
||||
export default function Spinner() {
|
||||
return (
|
||||
<div className="h-full flex">
|
||||
<div className="ring m-auto text-primary-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user