d7b5988858
Foundation: - CLAUDE.md with full coding rules (tech stack, patterns, redirect rules) - Install @tanstack/react-query, zod, react-hook-form, shadcn/ui deps - CSS variables design system (Tailwind v4 + tw-animate-css) - Shared components: Button, Input, Label, Card, Badge, Separator, Form - PortalLoginShell: two-column login layout (branding left, form right) - PortalNav: shared sidebar nav used by all portals - ReactQueryProvider in root layout - api-client.ts: typed fetch wrapper (no raw fetch in components) Portal isolation: - Sidebar now returns null for all non-chapter routes; portals own their layout - Admin layout: auth guard + PortalNav (slate accent) - Org layout: auth guard + PortalNav (violet accent) - Member layout: server-side cookie check → MemberNav client component - Chapter admin sidebar: PortalNav (blue accent) Login pages (all use react-hook-form + Zod + PortalLoginShell): - /login → defaults next to /search (was /) — fixes redirect bug - /admin/login → replaces /admin - /org/login → replaces /org - /member-login → two-step phone+OTP with proper form validation Portal selector (/) redesigned with accent border-left cards Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
101 lines
3.8 KiB
TypeScript
101 lines
3.8 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import * as LabelPrimitive from '@radix-ui/react-label';
|
|
import { Slot } from '@radix-ui/react-slot';
|
|
import { Controller, FormProvider, useFormContext, type ControllerProps, type FieldPath, type FieldValues } from 'react-hook-form';
|
|
import { cn } from '../../_lib/utils';
|
|
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>) => (
|
|
<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.ComponentRef<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.ComponentRef<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-sm 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-sm font-medium text-destructive', className)} {...props}>
|
|
{body}
|
|
</p>
|
|
);
|
|
},
|
|
);
|
|
FormMessage.displayName = 'FormMessage';
|
|
|
|
export { useFormField, Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField };
|