migrate to blitzjs
This commit is contained in:
84
app/core/components/form.tsx
Normal file
84
app/core/components/form.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { useState, ReactNode, PropsWithoutRef } from "react"
|
||||
import { FormProvider, useForm, UseFormProps } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod"
|
||||
|
||||
export interface FormProps<S extends z.ZodType<any, any>>
|
||||
extends Omit<PropsWithoutRef<JSX.IntrinsicElements["form"]>, "onSubmit"> {
|
||||
/** All your form fields */
|
||||
children?: ReactNode
|
||||
/** Text to display in the submit button */
|
||||
submitText?: string
|
||||
schema?: S
|
||||
onSubmit: (values: z.infer<S>) => Promise<void | OnSubmitResult>
|
||||
initialValues?: UseFormProps<z.infer<S>>["defaultValues"]
|
||||
}
|
||||
|
||||
interface OnSubmitResult {
|
||||
FORM_ERROR?: string
|
||||
|
||||
[prop: string]: any
|
||||
}
|
||||
|
||||
export const FORM_ERROR = "FORM_ERROR"
|
||||
|
||||
export function Form<S extends z.ZodType<any, any>>({
|
||||
children,
|
||||
submitText,
|
||||
schema,
|
||||
initialValues,
|
||||
onSubmit,
|
||||
...props
|
||||
}: FormProps<S>) {
|
||||
const ctx = useForm<z.infer<S>>({
|
||||
mode: "onBlur",
|
||||
resolver: schema ? zodResolver(schema) : undefined,
|
||||
defaultValues: initialValues,
|
||||
})
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<FormProvider {...ctx}>
|
||||
<form
|
||||
onSubmit={ctx.handleSubmit(async (values) => {
|
||||
const result = (await onSubmit(values)) || {}
|
||||
for (const [key, value] of Object.entries(result)) {
|
||||
if (key === FORM_ERROR) {
|
||||
setFormError(value)
|
||||
} else {
|
||||
ctx.setError(key as any, {
|
||||
type: "submit",
|
||||
message: value,
|
||||
})
|
||||
}
|
||||
}
|
||||
})}
|
||||
className="form"
|
||||
{...props}
|
||||
>
|
||||
{/* Form fields supplied as children are rendered here */}
|
||||
{children}
|
||||
|
||||
{formError && (
|
||||
<div role="alert" style={{ color: "red" }}>
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{submitText && (
|
||||
<button type="submit" disabled={ctx.formState.isSubmitting}>
|
||||
{submitText}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<style global jsx>{`
|
||||
.form > * + * {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
`}</style>
|
||||
</form>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default Form
|
58
app/core/components/labeled-text-field.tsx
Normal file
58
app/core/components/labeled-text-field.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import { forwardRef, PropsWithoutRef } from "react"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
|
||||
export interface LabeledTextFieldProps extends PropsWithoutRef<JSX.IntrinsicElements["input"]> {
|
||||
/** Field name. */
|
||||
name: string
|
||||
/** Field label. */
|
||||
label: string
|
||||
/** Field type. Doesn't include radio buttons and checkboxes */
|
||||
type?: "text" | "password" | "email" | "number"
|
||||
outerProps?: PropsWithoutRef<JSX.IntrinsicElements["div"]>
|
||||
}
|
||||
|
||||
export const LabeledTextField = forwardRef<HTMLInputElement, LabeledTextFieldProps>(
|
||||
({ label, outerProps, name, ...props }, ref) => {
|
||||
const {
|
||||
register,
|
||||
formState: { isSubmitting, errors },
|
||||
} = useFormContext()
|
||||
const error = Array.isArray(errors[name])
|
||||
? errors[name].join(", ")
|
||||
: errors[name]?.message || errors[name]
|
||||
|
||||
return (
|
||||
<div {...outerProps}>
|
||||
<label>
|
||||
{label}
|
||||
<input disabled={isSubmitting} {...register(name)} {...props} />
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<div role="alert" style={{ color: "red" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
font-size: 1rem;
|
||||
}
|
||||
input {
|
||||
font-size: 1rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
border: 1px solid purple;
|
||||
appearance: none;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export default LabeledTextField
|
11
app/core/hooks/use-current-customer.ts
Normal file
11
app/core/hooks/use-current-customer.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { useQuery } from "blitz"
|
||||
|
||||
import getCurrentCustomer from "../../customers/queries/get-current-customer"
|
||||
|
||||
export default function useCurrentCustomer() {
|
||||
const [customer] = useQuery(getCurrentCustomer, null)
|
||||
return {
|
||||
customer,
|
||||
hasCompletedOnboarding: Boolean(!!customer && customer.accountSid && customer.authToken),
|
||||
}
|
||||
}
|
15
app/core/hooks/use-customer-phone-number.ts
Normal file
15
app/core/hooks/use-customer-phone-number.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { useQuery } from "blitz"
|
||||
|
||||
import getCurrentCustomerPhoneNumber from "../../phone-numbers/queries/get-current-customer-phone-number"
|
||||
import useCurrentCustomer from "./use-current-customer"
|
||||
|
||||
export default function useCustomerPhoneNumber() {
|
||||
const { hasCompletedOnboarding } = useCurrentCustomer()
|
||||
const [customerPhoneNumber] = useQuery(
|
||||
getCurrentCustomerPhoneNumber,
|
||||
{},
|
||||
{ enabled: hasCompletedOnboarding }
|
||||
)
|
||||
|
||||
return customerPhoneNumber
|
||||
}
|
24
app/core/hooks/use-require-onboarding.ts
Normal file
24
app/core/hooks/use-require-onboarding.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { Routes, useRouter } from "blitz"
|
||||
|
||||
import useCurrentCustomer from "./use-current-customer"
|
||||
import useCustomerPhoneNumber from "./use-customer-phone-number"
|
||||
|
||||
export default function useRequireOnboarding() {
|
||||
const router = useRouter()
|
||||
const { customer, hasCompletedOnboarding } = useCurrentCustomer()
|
||||
const customerPhoneNumber = useCustomerPhoneNumber()
|
||||
|
||||
if (!hasCompletedOnboarding) {
|
||||
throw router.push(Routes.StepTwo())
|
||||
}
|
||||
|
||||
/*if (!customer.paddleCustomerId || !customer.paddleSubscriptionId) {
|
||||
throw router.push(Routes.StepTwo());
|
||||
return;
|
||||
}*/
|
||||
|
||||
console.log("customerPhoneNumber", customerPhoneNumber)
|
||||
if (!customerPhoneNumber) {
|
||||
throw router.push(Routes.StepThree())
|
||||
}
|
||||
}
|
22
app/core/layouts/base-layout.tsx
Normal file
22
app/core/layouts/base-layout.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { ReactNode } from "react"
|
||||
import { Head } from "blitz"
|
||||
|
||||
type LayoutProps = {
|
||||
title?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const BaseLayout = ({ title, children }: LayoutProps) => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{title || "virtual-phone"}</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default BaseLayout
|
81
app/core/layouts/layout/footer.tsx
Normal file
81
app/core/layouts/layout/footer.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import type { ReactNode } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/router"
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
|
||||
import {
|
||||
faPhoneAlt as fasPhone,
|
||||
faTh as fasTh,
|
||||
faComments as fasComments,
|
||||
faCog as fasCog,
|
||||
} from "@fortawesome/pro-solid-svg-icons"
|
||||
import {
|
||||
faPhoneAlt as farPhone,
|
||||
faTh as farTh,
|
||||
faComments as farComments,
|
||||
faCog as farCog,
|
||||
} from "@fortawesome/pro-regular-svg-icons"
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="grid grid-cols-4" style={{ flex: "0 0 50px" }}>
|
||||
<NavLink
|
||||
label="Calls"
|
||||
path="/calls"
|
||||
icons={{
|
||||
active: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={fasPhone} />,
|
||||
inactive: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={farPhone} />,
|
||||
}}
|
||||
/>
|
||||
<NavLink
|
||||
label="Keypad"
|
||||
path="/keypad"
|
||||
icons={{
|
||||
active: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={fasTh} />,
|
||||
inactive: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={farTh} />,
|
||||
}}
|
||||
/>
|
||||
<NavLink
|
||||
label="Messages"
|
||||
path="/messages"
|
||||
icons={{
|
||||
active: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={fasComments} />,
|
||||
inactive: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={farComments} />,
|
||||
}}
|
||||
/>
|
||||
<NavLink
|
||||
label="Settings"
|
||||
path="/settings"
|
||||
icons={{
|
||||
active: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={fasCog} />,
|
||||
inactive: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={farCog} />,
|
||||
}}
|
||||
/>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
type NavLinkProps = {
|
||||
path: string
|
||||
label: string
|
||||
icons: {
|
||||
active: ReactNode
|
||||
inactive: ReactNode
|
||||
}
|
||||
}
|
||||
|
||||
function NavLink({ path, label, icons }: NavLinkProps) {
|
||||
const router = useRouter()
|
||||
const isActiveRoute = router.pathname.startsWith(path)
|
||||
const icon = isActiveRoute ? icons.active : icons.inactive
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-around h-full">
|
||||
<Link href={path}>
|
||||
<a className="flex flex-col items-center">
|
||||
{icon}
|
||||
<span className="text-xs">{label}</span>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
101
app/core/layouts/layout/index.tsx
Normal file
101
app/core/layouts/layout/index.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import type { ErrorInfo, FunctionComponent } from "react"
|
||||
import { Component } from "react"
|
||||
import Head from "next/head"
|
||||
import type { WithRouterProps } from "next/dist/client/with-router"
|
||||
import { withRouter } from "next/router"
|
||||
|
||||
import appLogger from "../../../../integrations/logger"
|
||||
|
||||
import Footer from "./footer"
|
||||
|
||||
type Props = {
|
||||
title: string
|
||||
pageTitle?: string
|
||||
hideFooter?: true
|
||||
}
|
||||
|
||||
const logger = appLogger.child({ module: "Layout" })
|
||||
|
||||
const Layout: FunctionComponent<Props> = ({
|
||||
children,
|
||||
title,
|
||||
pageTitle = title,
|
||||
hideFooter = false,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{pageTitle ? (
|
||||
<Head>
|
||||
<title>{pageTitle}</title>
|
||||
</Head>
|
||||
) : null}
|
||||
|
||||
<div className="h-full w-full overflow-hidden fixed bg-gray-50">
|
||||
<div className="flex flex-col w-full h-full">
|
||||
<div className="flex flex-col flex-1 w-full overflow-y-auto">
|
||||
<main className="flex-1 my-0 h-full">
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
{!hideFooter ? <Footer /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type ErrorBoundaryState =
|
||||
| {
|
||||
isError: false
|
||||
}
|
||||
| {
|
||||
isError: true
|
||||
errorMessage: string
|
||||
}
|
||||
|
||||
const ErrorBoundary = withRouter(
|
||||
class ErrorBoundary extends Component<WithRouterProps, ErrorBoundaryState> {
|
||||
public readonly state = {
|
||||
isError: false,
|
||||
} as const
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return {
|
||||
isError: true,
|
||||
errorMessage: error.message,
|
||||
}
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
logger.error(error, errorInfo.componentStack)
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.state.isError) {
|
||||
return (
|
||||
<>
|
||||
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900">
|
||||
Oops, something went wrong.
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-lg leading-5 text-gray-600">
|
||||
Would you like to{" "}
|
||||
<button
|
||||
className="inline-flex space-x-2 items-center text-left"
|
||||
onClick={this.props.router.reload}
|
||||
>
|
||||
<span className="transition-colors duration-150 border-b border-primary-200 hover:border-primary-500">
|
||||
reload the page
|
||||
</span>
|
||||
</button>{" "}
|
||||
?
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export default Layout
|
3
app/core/styles/index.css
Normal file
3
app/core/styles/index.css
Normal file
@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
Reference in New Issue
Block a user