Add register modal and some UI fixes

This commit is contained in:
Artur N
2024-06-11 11:24:40 -03:00
parent ada9405b9c
commit c3d8b0ed36
33 changed files with 2654 additions and 205 deletions

17
components.json Normal file
View File

@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "styles/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "components",
"utils": "utils"
}
}

View File

@@ -14,7 +14,7 @@ const CustomLink = ({
if (isInternalLink) {
// @ts-ignore
return <Link href={href} {...rest} />
return <Link href={href} {...rest} />
}
if (isAnchorLink) {

View File

@@ -1,10 +1,17 @@
import { useState } from 'react'
import Link from './CustomLink'
import MobileNav from './MobileNav'
import ThemeSwitch from './ThemeSwitch'
import headerNavLinks from '../data/headerNavLinks'
import Logo from './Logo'
import { Dialog, DialogTrigger } from './ui/dialog'
import { Button } from './ui/button'
import RegisterFormModal from './RegisterFormModal'
const Header = () => {
const [registerIsOpen, setRegisterIsOpen] = useState(false)
return (
<header className="flex items-center justify-between py-10">
<div>
@@ -32,6 +39,13 @@ const Header = () => {
{link.title}
</Link>
))}
<Dialog open={registerIsOpen} onOpenChange={setRegisterIsOpen}>
<DialogTrigger asChild>
<Button>Register</Button>
</DialogTrigger>
<RegisterFormModal close={() => setRegisterIsOpen(false)} />
</Dialog>
</div>
<ThemeSwitch />
<MobileNav />

View File

@@ -1,6 +1,3 @@
'use client'
import { useTheme } from 'next-themes'
import { SVGProps } from 'react'
function Logo(props: SVGProps<SVGSVGElement>) {

View File

@@ -0,0 +1,145 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from './ui/dialog'
import { Input } from './ui/input'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from './ui/form'
import { Button } from './ui/button'
import { trpc } from '../utils/trpc'
import { useToast } from './ui/use-toast'
import { ReloadIcon } from '@radix-ui/react-icons'
const schema = z
.object({
email: z.string().email(),
password: z.string().min(8),
confirmPassword: z.string().min(8),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match.',
path: ['confirmPassword'],
})
type RegisterFormInputs = z.infer<typeof schema>
type Props = { close: () => void }
function RegisterFormModal({ close }: Props) {
const { toast } = useToast()
const form = useForm<RegisterFormInputs>({ resolver: zodResolver(schema) })
const registerMutation = trpc.auth.register.useMutation()
async function onSubmit(data: RegisterFormInputs) {
console.log(data)
try {
await registerMutation.mutateAsync(data)
toast({
title: 'Please check your email to verify your account.',
})
close()
} catch (error) {
const errorMessage = (error as any).message
if (errorMessage === 'EMAIL_TAKEN') {
return form.setError(
'email',
{ message: 'Email is already taken.' },
{ shouldFocus: true }
)
}
toast({
title: 'Sorry, something went wrong.',
variant: 'destructive',
})
}
}
return (
<DialogContent>
<DialogHeader>
<DialogTitle>Register</DialogTitle>
<DialogDescription>
Start supporting Monero projects today!
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col space-y-4"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="johndoe@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input {...field} type="password" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm password</FormLabel>
<FormControl>
<Input {...field} type="password" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting && (
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
)}{' '}
Register
</Button>
</form>
</Form>
</DialogContent>
)
}
export default RegisterFormModal

57
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,57 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../utils/cn'
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }

117
components/ui/dialog.tsx Normal file
View File

@@ -0,0 +1,117 @@
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { Cross2Icon } from '@radix-ui/react-icons'
import { cn } from '../../utils/cn'
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left',
className
)}
{...props}
/>
)
DialogHeader.displayName = 'DialogHeader'
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
)
DialogFooter.displayName = 'DialogFooter'
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

176
components/ui/form.tsx Normal file
View File

@@ -0,0 +1,176 @@
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { Slot } from '@radix-ui/react-slot'
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from 'react-hook-form'
import { cn } from '../../utils/cn'
import { Label } from './label'
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>')
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn('space-y-2', className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = 'FormItem'
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && 'text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = 'FormLabel'
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = 'FormControl'
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn('text-[0.8rem] text-muted-foreground', className)}
{...props}
/>
)
})
FormDescription.displayName = 'FormDescription'
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn('text-[0.8rem] font-medium text-destructive', className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = 'FormMessage'
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

25
components/ui/input.tsx Normal file
View File

@@ -0,0 +1,25 @@
import * as React from 'react'
import { cn } from '../../utils/cn'
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = 'Input'
export { Input }

24
components/ui/label.tsx Normal file
View File

@@ -0,0 +1,24 @@
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../utils/cn'
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

127
components/ui/toast.tsx Normal file
View File

@@ -0,0 +1,127 @@
import * as React from 'react'
import { Cross2Icon } from '@radix-ui/react-icons'
import * as ToastPrimitives from '@radix-ui/react-toast'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../utils/cn'
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
'group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
{
variants: {
variant: {
default: 'border bg-background text-foreground',
destructive:
'destructive group border-destructive bg-destructive text-destructive-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
'absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
className
)}
toast-close=""
{...props}
>
<Cross2Icon className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn('text-sm font-semibold [&+div]:text-xs', className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn('text-sm opacity-90', className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

33
components/ui/toaster.tsx Normal file
View File

@@ -0,0 +1,33 @@
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from './toast'
import { useToast } from './use-toast'
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

189
components/ui/use-toast.ts Normal file
View File

@@ -0,0 +1,189 @@
// Inspired by react-hot-toast library
import * as React from 'react'
import type { ToastActionElement, ToastProps } from './toast'
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST',
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType['ADD_TOAST']
toast: ToasterToast
}
| {
type: ActionType['UPDATE_TOAST']
toast: Partial<ToasterToast>
}
| {
type: ActionType['DISMISS_TOAST']
toastId?: ToasterToast['id']
}
| {
type: ActionType['REMOVE_TOAST']
toastId?: ToasterToast['id']
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: 'REMOVE_TOAST',
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_TOAST':
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case 'UPDATE_TOAST':
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case 'DISMISS_TOAST': {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case 'REMOVE_TOAST':
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, 'id'>
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: 'UPDATE_TOAST',
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id })
dispatch({
type: 'ADD_TOAST',
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
}
}
export { useToast, toast }

33
env.mjs Normal file
View File

@@ -0,0 +1,33 @@
// src/env.mjs
import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'
export const env = createEnv({
/*
* Serverside Environment variables, not available on the client.
* Will throw if you access these variables on the client.
*/
server: {
OPEN_AI_API_KEY: z.string().min(1),
},
/*
* Environment variables available on the client (and server).
*
* 💡 You'll get type errors if these are not prefixed with NEXT_PUBLIC_.
*/
client: {
// NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string().min(1),
},
/*
* Due to how Next.js bundles environment variables on Edge and Client,
* we need to manually destructure them to make sure all are included in bundle.
*
* 💡 You'll get type errors if not all variables from `server` & `client` are included here.
*/
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
OPEN_AI_API_KEY: process.env.OPEN_AI_API_KEY,
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:
process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
},
})

1295
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,19 +14,36 @@
"@fortawesome/free-brands-svg-icons": "^6.5.2",
"@fortawesome/free-solid-svg-icons": "^6.5.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@hookform/resolvers": "^3.6.0",
"@keycloak/keycloak-admin-client": "^24.0.5",
"@paralleldrive/cuid2": "^2.2.2",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-toast": "^1.1.5",
"@sendgrid/mail": "^8.1.3",
"@stripe/react-stripe-js": "^2.7.1",
"@stripe/stripe-js": "^3.4.1",
"@t3-oss/env-nextjs": "^0.10.1",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@tailwindcss/forms": "^0.5.7",
"@tanstack/react-query": "^5.40.1",
"@trpc/client": "^11.0.0-rc.390",
"@trpc/next": "^11.0.0-rc.390",
"@trpc/react-query": "^11.0.0-rc.390",
"@trpc/server": "^11.0.0-rc.390",
"@types/escape-html": "^1.0.4",
"base-64": "^1.0.0",
"class-variance-authority": "^0.7.0",
"classnames": "^2.5.1",
"clsx": "^2.1.1",
"dotenv": "^16.4.5",
"escape-html": "^1.0.3",
"exclude": "^1.0.0",
"fs": "^0.0.1-security",
"gray-matter": "^4.0.3",
"lucide-react": "^0.390.0",
"micro": "^10.0.1",
"micro-cors": "^0.1.1",
"next": "^14.2.3",
@@ -42,10 +59,13 @@
"sharp": "^0.33.4",
"stripe": "^15.9.0",
"swr": "^2.2.5",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7",
"typed.js": "^2.1.0",
"watch": "^0.13.0",
"wicg-inert": "^3.1.2",
"xss": "^1.0.15"
"xss": "^1.0.15",
"zod": "^3.23.8"
},
"devDependencies": {
"@svgr/webpack": "^8.1.0",

View File

@@ -1,23 +1,25 @@
import type { AppProps } from 'next/app'
import { ThemeProvider } from 'next-themes'
import '../styles/globals.css'
import '../styles/tailwind.css'
import Layout from '../components/Layout'
import Head from 'next/head'
function MyApp({ Component, pageProps }: AppProps) {
import Layout from '../components/Layout'
import { trpc } from '../utils/trpc'
import '../styles/globals.css'
import { Toaster } from '../components/ui/toaster'
function MyApp({ Component, pageProps }: AppProps) {
return (
<ThemeProvider attribute="class" defaultTheme='system'>
<ThemeProvider attribute="class" defaultTheme="system">
<Head>
<meta content="width=device-width, initial-scale=1" name="viewport" />
</Head>
<Layout>
<Component {...pageProps} />
</Layout>
<Toaster />
</ThemeProvider>
)
}
export default MyApp
export default trpc.withTRPC(MyApp)

View File

@@ -1,7 +1,7 @@
import xss from 'xss'
import markdownToHtml from '../utils/markdownToHtml'
import { getSingleFile } from '../utils/md'
import BigDumbMarkdown from '../components/BigDumbMarkdown'
import xss from 'xss'
export default function About({ content }: { content: string }) {
return (

9
pages/api/trpc/[trpc].ts Normal file
View File

@@ -0,0 +1,9 @@
import * as trpcNext from '@trpc/server/adapters/next'
import { appRouter } from '../../../server/routers/_app'
// export API handler
// @link https://trpc.io/docs/v11/server/adapters
export default trpcNext.createNextApiHandler({
router: appRouter,
createContext: () => ({}),
})

View File

@@ -3,6 +3,7 @@ import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { fetchPostJSON } from '../utils/api-helpers'
import Link from 'next/link'
import { Button } from '../components/ui/button'
export default function Apply() {
async function handleClick() {}
const [loading, setLoading] = useState(false)
@@ -29,7 +30,7 @@ export default function Apply() {
<div className="mx-auto flex-1 flex flex-col items-center justify-center gap-4 py-8 prose dark:prose-dark">
<form
onSubmit={handleSubmit(onSubmit)}
className="apply flex flex-col gap-4 p-4 max-w-2xl"
className="max-w-5xl flex flex-col gap-4 p-4"
>
<div>
<h1>
@@ -296,12 +297,7 @@ export default function Apply() {
need to identify themselves to MAGIC, in accordance with US law.
</small>
<button
className="mb-2 mr-2 w-full rounded bg-orange-500 px-4 text-xl font-semibold text-white hover:border-transparent hover:bg-orange-500 hover:text-black dark:text-black dark:hover:text-white md:max-w-[98%] disabled:opacity-50 disabled:cursor-not-allowed"
disabled={loading}
>
Apply
</button>
<Button disabled={loading}>Apply</Button>
<p>
After submitting your application, please allow our team up to three

View File

@@ -4,14 +4,13 @@ import { useEffect, useState } from 'react'
import ProjectList from '../components/ProjectList'
import PaymentModal from '../components/PaymentModal'
import Link from 'next/link'
import Image from 'next/image'
import magiclogo from '/public/img/crystalball.jpg'
import { getAllPosts } from '../utils/md'
import { ProjectItem } from '../utils/types'
import { useRouter } from 'next/router'
import Typing from '../components/Typing'
import CustomLink from '../components/CustomLink'
import { Button } from '../components/ui/button'
// These shouldn't be swept up in the regular list so we hardcode them
const generalFund: ProjectItem = {
@@ -73,12 +72,13 @@ const Home: NextPage<{ projects: any }> = ({ projects }) => {
</p>
<div className="flex flex-wrap py-4">
<div className="w-full md:w-1/2">
<button
<Button
onClick={openGeneralFundModal}
className="mb-2 mr-2 w-full rounded bg-orange-500 px-4 text-xl font-semibold text-white hover:border-transparent hover:bg-orange-500 hover:text-black dark:text-black dark:hover:text-white md:max-w-[98%]"
size="lg"
className="px-14 text-black font-semibold text-xl"
>
Donate to Monero Comittee General Fund
</button>
</Button>
</div>
</div>
<p className="text-lg leading-7 text-gray-500 dark:text-gray-400">

View File

@@ -1,17 +1,29 @@
import Link from 'next/link'
export default function ThankYou() {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-4 py-8">
<h2>Thank you for your donation!</h2>
<p>
If you have any questions, please reach out to{' '}
<a href="mailto:info@magicgrants.org">info@magicgrants.org</a>
</p>
.
<p>
<Link href="/">Return Home</Link>
</p>
</div>
)
return (
<div className="flex-1 flex flex-col items-center justify-center gap-4 py-8">
<h2>Thank you for your donation!</h2>
<p>
If you have any questions, please reach out to{' '}
<a
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
href="mailto:info@magicgrants.org"
>
info@magicgrants.org
</a>
</p>
<br />
<p>
<Link
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
href="/"
>
Return Home
</Link>
</p>
</div>
)
}

8
server/routers/_app.ts Normal file
View File

@@ -0,0 +1,8 @@
import { mergeRouters, router } from '../trpc'
import { authRouter } from './auth'
export const appRouter = router({
auth: authRouter,
})
export type AppRouter = typeof appRouter

44
server/routers/auth.ts Normal file
View File

@@ -0,0 +1,44 @@
import { z } from 'zod'
import { createId } from '@paralleldrive/cuid2'
import { publicProcedure, router } from '../trpc'
import { authenticateKeycloakClient } from '../utils/keycloak'
import { keycloak } from '../services'
import { TRPCError } from '@trpc/server'
export const authRouter = router({
register: publicProcedure
.input(z.object({ email: z.string().email(), password: z.string() }))
.mutation(async ({ input }) => {
console.log(input)
await authenticateKeycloakClient()
try {
const user = await keycloak.users.create({
realm: 'monerofund',
email: input.email,
credentials: [
{ type: 'password', value: input.password, temporary: false },
],
requiredActions: ['VERIFY_EMAIL'],
enabled: true,
})
await keycloak.users.executeActionsEmail({
id: user.id,
actions: ['VERIFY_EMAIL'],
})
} catch (error) {
if (
(error as any).responseData.errorMessage ===
'User exists with same email'
) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'EMAIL_TAKEN' })
}
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'UNKNOWN_ERROR',
})
}
}),
})

6
server/services.ts Normal file
View File

@@ -0,0 +1,6 @@
import KeycloakAdminClient from '@keycloak/keycloak-admin-client'
export const keycloak = new KeycloakAdminClient({
baseUrl: 'http://localhost:8080',
realmName: 'monerofund',
})

12
server/trpc.ts Normal file
View File

@@ -0,0 +1,12 @@
import { initTRPC } from '@trpc/server'
// Avoid exporting the entire t-object
// since it's not very descriptive.
// For instance, the use of a t variable
// is common in i18n libraries.
const t = initTRPC.create()
// Base router and procedure helpers
export const router = t.router
export const publicProcedure = t.procedure
export const mergeRouters = t.mergeRouters

8
server/utils/keycloak.ts Normal file
View File

@@ -0,0 +1,8 @@
import { keycloak } from '../services'
export const authenticateKeycloakClient = () =>
keycloak.auth({
clientId: 'app',
clientSecret: '7JryN6EVIYtCwN4iHheacjp986Rfy5FJ',
grantType: 'client_credentials',
})

View File

@@ -1,34 +1,139 @@
button.small {
@apply p-1 text-sm bg-white text-black border border-black;
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 25 95% 53%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84% 60%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 0, 0%, 9%;
--foreground: 210 40% 98%;
--card: 25 95% 53%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 25 95% 53%;
--primary-foreground: 0 0% 100%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 0 0% 60%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 84% 60%;
--destructive-foreground: 210 40% 98%;
--border: 0 0% 20%;
--input: 0 0% 20%;
--ring: var(--primary);
}
}
button.group {
@apply p-2 text-sm bg-white text-black border border-black;
@layer base {
* {
@apply border-border;
}
}
button.pay {
@apply rounded border border-orange-500 bg-transparent font-semibold text-orange-500 hover:border-transparent hover:bg-orange-500 hover:text-white flex-1 min-w-[20rem] border-2 border-black bg-white p-8 text-xl rounded-xl flex justify-start gap-4;
body {
background-color: hsl(var(--background));
}
button.pay:disabled {
@apply bg-gray-100 border-gray-400 text-gray-400 hover:shadow-none;
.task-list-item::before {
@apply hidden;
}
input[type='checkbox'] {
@apply mr-2;
.task-list-item {
@apply list-none;
}
.video-container {
position: relative;
width: 100%;
padding-bottom: 56.25%;
.footnotes {
@apply mt-12 border-t border-gray-200 pt-8 dark:border-gray-700;
}
.video-container iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
.data-footnote-backref {
@apply no-underline;
}
.csl-entry {
@apply my-5;
}
/* https://stackoverflow.com/questions/61083813/how-to-avoid-internal-autofill-selected-style-to-be-applied */
input:-webkit-autofill,
input:-webkit-autofill:focus {
transition:
background-color 600000s 0s,
color 600000s 0s;
}
@layer base {
h1 {
@apply text-2xl;
}
h2 {
@apply text-xl;
}
h3 {
@apply text-lg;
}
}
.prose > ul > li > p {
margin-top: 0;
margin-bottom: 0;
}
.prose small {
@apply leading-normal;
}
form label {
@apply text-sm font-semibold leading-normal;
}
.checkbox {
@apply flex items-center;
}
form input[type='text'],
form textarea {
@apply mt-2;
}

View File

@@ -1,90 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.task-list-item::before {
@apply hidden;
}
.task-list-item {
@apply list-none;
}
.footnotes {
@apply mt-12 border-t border-gray-200 pt-8 dark:border-gray-700;
}
.data-footnote-backref {
@apply no-underline;
}
.csl-entry {
@apply my-5;
}
/* https://stackoverflow.com/questions/61083813/how-to-avoid-internal-autofill-selected-style-to-be-applied */
input:-webkit-autofill,
input:-webkit-autofill:focus {
transition:
background-color 600000s 0s,
color 600000s 0s;
}
@layer base {
h1 {
@apply text-2xl;
}
h2 {
@apply text-xl;
}
h3 {
@apply text-lg;
}
}
button.small {
@apply p-1 text-sm bg-white text-black border border-black;
}
button.group {
@apply p-2 text-sm bg-white text-black border border-black;
}
button.pay {
@apply flex-1 min-w-[20rem] border-2 border-black bg-white p-8 text-xl text-black rounded-xl flex justify-start gap-4;
}
button.pay:disabled {
@apply bg-gray-100 border-gray-400 text-gray-400 hover:shadow-none;
}
button,
button[type='submit'] {
@apply py-2 px-4 text-white rounded;
}
button.secondary {
@apply bg-white text-black border;
}
.prose > ul > li > p {
margin-top: 0;
margin-bottom: 0;
}
.prose small {
@apply leading-normal;
}
form label {
@apply text-sm font-semibold leading-normal;
}
.checkbox {
@apply flex items-center;
}
form input[type='text'],
form textarea {
@apply mt-2;
}

View File

@@ -1,47 +1,85 @@
// @ts-check
const { fontFamily } = require('tailwindcss/defaultTheme')
const colors = require('tailwindcss/colors')
// ../node_modules/pliny/dist/**/*.mjs is needed for monorepo setup
/** @type {import("tailwindcss/types").Config } */
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ['class'],
content: [
'../node_modules/pliny/**/*.{js,ts,tsx}',
'./node_modules/pliny/**/*.{js,ts,tsx}',
'./app/**/*.{js,ts,jsx,tsx}',
'./pages/**/*.{js,ts,tsx}',
'./components/**/*.{js,ts,tsx}',
'./layouts/**/*.{js,ts,tsx}',
'./lib/**/*.{js,ts,tsx}',
'./data/**/*.mdx',
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
darkMode: 'class',
prefix: '',
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px',
},
},
extend: {
spacing: {
'9/16': '56.25%',
},
lineHeight: {
11: '2.75rem',
12: '3rem',
13: '3.25rem',
14: '3.5rem',
},
fontFamily: {
sans: ['Inter', ...fontFamily.sans],
},
colors: {
primary: colors.orange,
gray: colors.neutral,
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
DEFAULT_HOVER: 'hsl(var(--primary) / 0.7)',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
typography: (theme) => ({
DEFAULT: {
css: {
color: theme('colors.gray.700'),
a: {
color: theme('colors.primary.500'),
color: theme('colors.primary.DEFAULT'),
'&:hover': {
color: `${theme('colors.primary.600')} !important`,
color: `${theme('colors.primary.DEFAULT_HOVER')} !important`,
},
code: { color: theme('colors.primary.400') },
},
@@ -107,9 +145,9 @@ module.exports = {
css: {
color: theme('colors.gray.300'),
a: {
color: theme('colors.primary.500'),
color: theme('colors.primary.DEFAULT'),
'&:hover': {
color: `${theme('colors.primary.400')} !important`,
color: `${theme('colors.primary.DEFAULT_HOVER')} !important`,
},
code: { color: theme('colors.primary.400') },
},
@@ -168,7 +206,7 @@ module.exports = {
},
},
plugins: [
require('@tailwindcss/forms'),
require('tailwindcss-animate'),
require('@tailwindcss/typography'),
require('@tailwindcss/aspect-ratio'),
],

View File

@@ -1,10 +1,15 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
@@ -15,6 +20,12 @@
"jsx": "preserve",
"incremental": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
]
}

6
utils/cn.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

47
utils/trpc.ts Normal file
View File

@@ -0,0 +1,47 @@
import { httpBatchLink } from '@trpc/client'
import { createTRPCNext } from '@trpc/next'
import type { AppRouter } from '../server/routers/_app'
function getBaseUrl() {
if (typeof window !== 'undefined')
// browser should use relative path
return ''
if (process.env.VERCEL_URL)
// reference for vercel.com
return `https://${process.env.VERCEL_URL}`
if (process.env.RENDER_INTERNAL_HOSTNAME)
// reference for render.com
return `http://${process.env.RENDER_INTERNAL_HOSTNAME}:${process.env.PORT}`
// assume localhost
return `http://localhost:${process.env.PORT ?? 3000}`
}
export const trpc = createTRPCNext<AppRouter>({
config(opts) {
return {
links: [
httpBatchLink({
/**
* If you want to use SSR, you need to use the server's full URL
* @link https://trpc.io/docs/v11/ssr
**/
url: `${getBaseUrl()}/api/trpc`,
// You can pass any HTTP headers you wish here
async headers() {
return {
// authorization: getAuthCookie(),
}
},
}),
],
}
},
/**
* @link https://trpc.io/docs/v11/ssr
**/
ssr: false,
})