Sfoglia il codice sorgente

feat(dashboard): Enhance login form and add UI configuration support (#3414)

David Höck 10 mesi fa
parent
commit
8dc58d2e53

+ 148 - 81
packages/dashboard/src/components/login-form.tsx

@@ -1,12 +1,19 @@
 import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert.js';
 import { Trans } from '@lingui/react/macro';
-import { cn } from '@/lib/utils';
-import { Button } from '@/components/ui/button';
-import { Card, CardContent } from '@/components/ui/card';
-import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';
+import { cn } from '@/lib/utils.js';
+import { Button } from '@/components/ui/button.js';
+import { Card, CardContent } from '@/components/ui/card.js';
+import { Input } from '@/components/ui/input.js';
+import { Label } from '@/components/ui/label.js';
 import { AlertCircle, Loader2 } from 'lucide-react';
 import * as React from 'react';
+import { uiConfig } from 'virtual:vendure-ui-config';
+import { LogoMark } from './shared/logo-mark.js';
+import { z } from 'zod';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from './ui/form.js';
+import { toast } from 'sonner';
 
 export interface LoginFormProps extends React.ComponentProps<'div'> {
     loginError?: string;
@@ -14,95 +21,155 @@ export interface LoginFormProps extends React.ComponentProps<'div'> {
     onFormSubmit?: (username: string, password: string) => void;
 }
 
+type RemoteLoginImage = {
+    urls: { regular: string };
+    location: { name: string };
+    user: { name: string; links: { html: string } };
+};
+
+const formSchema = z.object({
+    username: z.string().min(1),
+    password: z.string().min(1),
+});
+
 export function LoginForm({ className, onFormSubmit, isVerifying, loginError, ...props }: LoginFormProps) {
-    function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
-        e.preventDefault();
-        const data = new FormData(e.currentTarget);
-        const fieldValue = data.get('username');
-        if (!fieldValue) return;
-        const username = fieldValue.toString();
-        const password = data.get('password')?.toString() || '';
-        onFormSubmit?.(username, password);
-    }
+    const [remoteLoginImage, setRemoteLoginImage] = React.useState<RemoteLoginImage | null>(null);
+
+    React.useEffect(() => {
+        if (!uiConfig.loginImageUrl) {
+            fetch('https://login-image.vendure.io')
+                .then(res => res.json())
+                .then(data => setRemoteLoginImage(data));
+        }
+    }, []);
+
+    React.useEffect(() => {
+        if (loginError && !isVerifying) {
+            toast.error(loginError);
+        }
+    }, [loginError, isVerifying]);
+
+    const form = useForm<z.infer<typeof formSchema>>({
+        resolver: zodResolver(formSchema),
+        defaultValues: {
+            username: '',
+            password: '',
+        },
+    });
 
     return (
-        <div className={cn('flex flex-col gap-6', className)} {...props}>
+        <div className={cn('flex flex-col gap-6 bg-sidebar', className)} {...props}>
             <Card className="overflow-hidden">
                 <CardContent className="grid p-0 md:grid-cols-2">
-                    <form className="p-6 md:p-8" onSubmit={handleSubmit}>
-                        <div className="flex flex-col gap-6">
-                            <div className="flex flex-col items-center text-center">
-                                <h1 className="text-2xl font-bold">
-                                    <Trans>Welcome back!</Trans>
-                                </h1>
-                                <p className="text-muted-foreground text-balance">
-                                    Login to your Acme Inc account
-                                </p>
-                            </div>
-                            <div className="grid gap-3">
-                                <Label htmlFor="email">
-                                    <Trans>User</Trans>
-                                </Label>
-                                <Input
+                    <Form {...form}>
+                        <form
+                            className="p-6 md:p-8"
+                            onSubmit={form.handleSubmit(data => onFormSubmit?.(data.username, data.password))}
+                        >
+                            <div className="flex flex-col gap-6">
+                                <div className="flex flex-col items-center text-center space-y-2">
+                                    {!uiConfig.hideVendureBranding && (
+                                        <LogoMark className="text-brand h-10 w-auto" />
+                                    )}
+                                    <div>
+                                        <h1 className="text-2xl font-medium">
+                                            <Trans>Welcome back!</Trans>
+                                        </h1>
+                                        <p className="text-muted-foreground text-balance">
+                                            Login to your Vendure store
+                                        </p>
+                                    </div>
+                                </div>
+                                <FormField
+                                    control={form.control}
                                     name="username"
-                                    id="username"
-                                    type="username"
-                                    placeholder="Username or email address"
-                                    required
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <FormLabel htmlFor="email" asChild>
+                                                <Trans>Username</Trans>
+                                            </FormLabel>
+                                            <FormControl>
+                                                <Input {...field} placeholder="Username or email address" />
+                                            </FormControl>
+                                            <FormMessage />
+                                        </FormItem>
+                                    )}
                                 />
-                            </div>
-                            <div className="grid gap-3">
-                                <div className="flex items-center">
-                                    <Label htmlFor="password">
-                                        <Trans>Password</Trans>
-                                    </Label>
-                                    <a
-                                        href="#"
-                                        className="ml-auto text-sm underline-offset-2 hover:underline"
-                                    >
-                                        Forgot your password?
-                                    </a>
-                                </div>
-                                <Input name="password" id="password" type="password" required />
-                            </div>
-                            {loginError && (
-                                <Alert variant="destructive">
-                                    <AlertCircle className="h-4 w-4" />
-                                    <AlertTitle>Error</AlertTitle>
-                                    <AlertDescription>{loginError}</AlertDescription>
-                                </Alert>
-                            )}
-                            {isVerifying ? (
-                                <Button disabled>
-                                    <Loader2 className="animate-spin" />
-                                    Please wait
-                                </Button>
-                            ) : (
-                                <Button type="submit" className="w-full">
-                                    Login
+                                <FormField
+                                    control={form.control}
+                                    name="password"
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <div className="flex items-center">
+                                                <FormLabel htmlFor="password" asChild>
+                                                    <Trans>Password</Trans>
+                                                </FormLabel>
+                                                <a
+                                                    tabIndex={-1}
+                                                    href="#"
+                                                    className="ml-auto text-sm underline-offset-2 hover:underline"
+                                                >
+                                                    Forgot your password?
+                                                </a>
+                                            </div>
+                                            <FormControl>
+                                                <Input {...field} type="password" />
+                                            </FormControl>
+                                            <FormMessage />
+                                        </FormItem>
+                                    )}
+                                />
+
+                                <Button type="submit" disabled={isVerifying}>
+                                    {isVerifying && (
+                                        <>
+                                            <Loader2 className="animate-spin" />
+                                            Please wait
+                                        </>
+                                    )}
+                                    {!isVerifying && <span>Login</span>}
                                 </Button>
-                            )}
-                            <div className="text-center text-sm">
-                                Don&apos;t have an account?{' '}
-                                <a href="#" className="underline underline-offset-4">
-                                    Sign up
-                                </a>
                             </div>
-                        </div>
-                    </form>
+                        </form>
+                    </Form>
                     <div className="bg-muted relative hidden md:block">
-                        <img
-                            src="/placeholder.svg"
-                            alt="Image"
-                            className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale"
-                        />
+                        {remoteLoginImage && (
+                            <>
+                                <img
+                                    src={remoteLoginImage.urls.regular}
+                                    alt="Image"
+                                    className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale"
+                                />
+                                <div className="absolute h-full w-full top-0 left-0 flex items-end justify-start bg-gradient-to-b from-transparent to-black/80 p-4 ">
+                                    <div>
+                                        <p className="text-lg font-medium text-white">
+                                            {remoteLoginImage.location.name}
+                                        </p>
+                                        <p className="text-sm text-white/80">
+                                            By
+                                            <a
+                                                className="mx-1 underline"
+                                                href={remoteLoginImage.user.links.html}
+                                                target="_blank"
+                                            >
+                                                {remoteLoginImage.user.name}
+                                            </a>
+                                            on Unsplash
+                                        </p>
+                                    </div>
+                                </div>
+                            </>
+                        )}
+                        {uiConfig.loginImageUrl && (
+                            <img
+                                src={uiConfig.loginImageUrl}
+                                alt="Login image"
+                                className="absolute inset-0 h-full w-full object-cover"
+                            />
+                        )}
                     </div>
                 </CardContent>
             </Card>
-            <div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
-                By clicking continue, you agree to our <a href="#">Terms of Service</a> and{' '}
-                <a href="#">Privacy Policy</a>.
-            </div>
         </div>
     );
 }

+ 18 - 0
packages/dashboard/src/components/shared/icon-mark.tsx

@@ -0,0 +1,18 @@
+export function IconMark(props: React.ComponentProps<'svg'>) {
+    return (
+        <svg viewBox="0 0 40 28" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
+            <path
+                d="M10.7466 12.6853V21.9481C10.7466 22.1142 10.8389 22.2712 10.9836 22.3527L19.3904 27.1155C19.6921 27.2846 20.0615 27.2846 20.3632 27.1155L28.77 22.3527C28.9178 22.2682 29.007 22.1142 29.007 21.9481V12.6853C29.007 12.3259 28.6099 12.0994 28.2929 12.2806L20.3632 16.7716C20.0615 16.9407 19.6921 16.9407 19.3904 16.7716L11.4607 12.2806C11.1437 12.0994 10.7466 12.3259 10.7466 12.6853Z"
+                fill="currentColor"
+            />
+            <path
+                d="M8.8932 0.749702L0.486371 5.50943C0.184698 5.67856 0 5.99568 0 6.33393V15.8564C0 16.0225 0.0923489 16.1796 0.237029 16.2611L8.41299 20.894C8.73005 21.0752 9.12715 20.8487 9.12715 20.4893V11.5074C9.12715 11.1661 9.31185 10.852 9.61352 10.6829L17.5432 6.19199C17.8603 6.01078 17.8603 5.56077 17.5432 5.38258L9.36726 0.749702C9.2195 0.665138 9.03788 0.665138 8.89012 0.749702H8.8932Z"
+                fill="currentColor"
+            />
+            <path
+                d="M30.86 0.740669L39.2668 5.5004C39.5685 5.66953 39.7532 5.98664 39.7532 6.3249V15.8474C39.7532 16.0135 39.6608 16.1705 39.5162 16.2521L31.3402 20.885C31.0231 21.0662 30.626 20.8397 30.626 20.4803V11.4984C30.626 11.1571 30.4413 10.843 30.1397 10.6739L22.21 6.18295C21.8929 6.00174 21.8929 5.55174 22.21 5.37355L30.3859 0.740669C30.5337 0.656105 30.7153 0.656105 30.8631 0.740669H30.86Z"
+                fill="currentColor"
+            />
+        </svg>
+    );
+}

File diff suppressed because it is too large
+ 4 - 0
packages/dashboard/src/components/shared/logo-mark.tsx


+ 137 - 149
packages/dashboard/src/components/ui/form.tsx

@@ -1,177 +1,165 @@
-import * as React from 'react';
-import * as LabelPrimitive from '@radix-ui/react-label';
-import { Slot } from '@radix-ui/react-slot';
+import * as React from "react"
+import * as LabelPrimitive from "@radix-ui/react-label"
+import { Slot } from "@radix-ui/react-slot"
 import {
-    Controller,
-    FormProvider,
-    useFormContext,
-    useFormState,
-    type ControllerProps,
-    type FieldPath,
-    type FieldValues,
-} from 'react-hook-form';
+  Controller,
+  FormProvider,
+  useFormContext,
+  useFormState,
+  type ControllerProps,
+  type FieldPath,
+  type FieldValues,
+} from "react-hook-form"
 
-import { cn } from '@/lib/utils';
-import { Label } from '@/components/ui/label';
-import { useUserSettings } from '@/providers/user-settings.js';
+import { cn } from "@/lib/utils"
+import { Label } from "@/components/ui/label"
 
-const Form = FormProvider;
+const Form = FormProvider
 
 type FormFieldContextValue<
-    TFieldValues extends FieldValues = FieldValues,
-    TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
+  TFieldValues extends FieldValues = FieldValues,
+  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
 > = {
-    name: TName;
-};
+  name: TName
+}
 
-const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
+const FormFieldContext = React.createContext<FormFieldContextValue>(
+  {} as FormFieldContextValue
+)
 
 const FormField = <
-    TFieldValues extends FieldValues = FieldValues,
-    TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
+  TFieldValues extends FieldValues = FieldValues,
+  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
 >({
-    ...props
+  ...props
 }: ControllerProps<TFieldValues, TName>) => {
-    return (
-        <FormFieldContext.Provider value={{ name: props.name }}>
-            <Controller {...props} />
-        </FormFieldContext.Provider>
-    );
-};
-
-export type TranslatableFormFieldProps = {};
-
-const TranslatableFormField = <
-    TFieldValues extends FieldValues & { translations?: Array<{ languageCode: string }> | null } = FieldValues,
->({
-    name,
-    ...props
-}: Omit<ControllerProps<TFieldValues>, 'name'> & { 
-    name: keyof Omit<NonNullable<TFieldValues['translations']>[number], 'languageCode'>;
-}) => {
-    const { contentLanguage } = useUserSettings().settings;
-    const index = props.control?._formValues?.translations?.findIndex(
-        (translation: any) => translation?.languageCode === contentLanguage,
-    );
-    if (index === undefined || index === -1) {
-        return null;
-    }
-    const translationName = `translations.${index}.${String(name)}` as FieldPath<TFieldValues>;
-    return (
-        <FormFieldContext.Provider value={{ name: translationName }}>
-            <Controller {...props} name={translationName} key={translationName} />
-        </FormFieldContext.Provider>
-    );
-};
+  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 } = useFormContext();
-    const formState = useFormState({ name: fieldContext.name });
-    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,
-    };
-};
+  const fieldContext = React.useContext(FormFieldContext)
+  const itemContext = React.useContext(FormItemContext)
+  const { getFieldState } = useFormContext()
+  const formState = useFormState({ name: fieldContext.name })
+  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);
-
-function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
-    const id = React.useId();
+  id: string
+}
 
-    return (
-        <FormItemContext.Provider value={{ id }}>
-            <div data-slot="form-item" className={cn('grid gap-2', className)} {...props} />
-        </FormItemContext.Provider>
-    );
+const FormItemContext = React.createContext<FormItemContextValue>(
+  {} as FormItemContextValue
+)
+
+function FormItem({ className, ...props }: React.ComponentProps<"div">) {
+  const id = React.useId()
+
+  return (
+    <FormItemContext.Provider value={{ id }}>
+      <div
+        data-slot="form-item"
+        className={cn("grid gap-2", className)}
+        {...props}
+      />
+    </FormItemContext.Provider>
+  )
 }
 
-function FormLabel({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
-    const { error, formItemId } = useFormField();
-
-    return (
-        <Label
-            data-slot="form-label"
-            data-error={!!error}
-            className={cn('data-[error=true]:text-destructive-foreground', className)}
-            htmlFor={formItemId}
-            {...props}
-        />
-    );
+function FormLabel({
+  className,
+  ...props
+}: React.ComponentProps<typeof LabelPrimitive.Root>) {
+  const { error, formItemId } = useFormField()
+
+  return (
+    <Label
+      data-slot="form-label"
+      data-error={!!error}
+      className={cn("data-[error=true]:text-destructive", className)}
+      htmlFor={formItemId}
+      {...props}
+    />
+  )
 }
 
 function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
-    const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
-
-    return (
-        <Slot
-            data-slot="form-control"
-            id={formItemId}
-            aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
-            aria-invalid={!!error}
-            {...props}
-        />
-    );
+  const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
+
+  return (
+    <Slot
+      data-slot="form-control"
+      id={formItemId}
+      aria-describedby={
+        !error
+          ? `${formDescriptionId}`
+          : `${formDescriptionId} ${formMessageId}`
+      }
+      aria-invalid={!!error}
+      {...props}
+    />
+  )
 }
 
-function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
-    const { formDescriptionId } = useFormField();
-
-    return (
-        <p
-            data-slot="form-description"
-            id={formDescriptionId}
-            className={cn('text-muted-foreground text-sm', className)}
-            {...props}
-        />
-    );
+function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
+  const { formDescriptionId } = useFormField()
+
+  return (
+    <p
+      data-slot="form-description"
+      id={formDescriptionId}
+      className={cn("text-muted-foreground text-sm", className)}
+      {...props}
+    />
+  )
 }
 
-function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
-    const { error, formMessageId } = useFormField();
-    const body = error ? String(error?.message ?? '') : props.children;
-
-    if (!body) {
-        return null;
-    }
-
-    return (
-        <p
-            data-slot="form-message"
-            id={formMessageId}
-            className={cn('text-destructive-foreground text-sm', className)}
-            {...props}
-        >
-            {body}
-        </p>
-    );
+function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
+  const { error, formMessageId } = useFormField()
+  const body = error ? String(error?.message ?? "") : props.children
+
+  if (!body) {
+    return null
+  }
+
+  return (
+    <p
+      data-slot="form-message"
+      id={formMessageId}
+      className={cn("text-destructive text-sm", className)}
+      {...props}
+    >
+      {body}
+    </p>
+  )
 }
 
 export {
-    useFormField,
-    Form,
-    FormItem,
-    FormLabel,
-    FormControl,
-    FormDescription,
-    FormMessage,
-    FormField,
-    TranslatableFormField,
-};
+  useFormField,
+  Form,
+  FormItem,
+  FormLabel,
+  FormControl,
+  FormDescription,
+  FormMessage,
+  FormField,
+}

+ 3 - 1
packages/dashboard/src/components/ui/label.tsx

@@ -1,3 +1,5 @@
+"use client"
+
 import * as React from "react"
 import * as LabelPrimitive from "@radix-ui/react-label"
 
@@ -11,7 +13,7 @@ function Label({
     <LabelPrimitive.Root
       data-slot="label"
       className={cn(
-        "text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
+        "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
         className
       )}
       {...props}

+ 28 - 0
packages/dashboard/src/framework/internal/form/field.tsx

@@ -0,0 +1,28 @@
+import { Controller } from 'react-hook-form';
+import { FieldPath } from 'react-hook-form';
+import { useUserSettings } from '@/providers/user-settings.js';
+import { ControllerProps } from 'react-hook-form';
+import { FieldValues } from 'react-hook-form';
+
+export type TranslatableFormFieldProps = {};
+
+export const TranslatableFormField = <
+    TFieldValues extends FieldValues & {
+        translations?: Array<{ languageCode: string }> | null;
+    } = FieldValues,
+>({
+    name,
+    ...props
+}: Omit<ControllerProps<TFieldValues>, 'name'> & {
+    name: keyof Omit<NonNullable<TFieldValues['translations']>[number], 'languageCode'>;
+}) => {
+    const { contentLanguage } = useUserSettings().settings;
+    const index = props.control?._formValues?.translations?.findIndex(
+        (translation: any) => translation?.languageCode === contentLanguage,
+    );
+    if (index === undefined || index === -1) {
+        return null;
+    }
+    const translationName = `translations.${index}.${String(name)}` as FieldPath<TFieldValues>;
+    return <Controller {...props} name={translationName} key={translationName} />;
+};

+ 1 - 1
packages/dashboard/src/routes/_authenticated/products_.$id.tsx

@@ -10,12 +10,12 @@ import {
     FormItem,
     FormLabel,
     FormMessage,
-    TranslatableFormField,
 } from '@/components/ui/form.js';
 import { Input } from '@/components/ui/input.js';
 import { Switch } from '@/components/ui/switch.js';
 import { Textarea } from '@/components/ui/textarea.js';
 import { useGeneratedForm } from '@/framework/internal/form-engine/use-generated-form.js';
+import { TranslatableFormField } from '@/framework/internal/form/field.js';
 import { DetailPage, getDetailQueryOptions } from '@/framework/internal/page/detail-page.js';
 import { api } from '@/graphql/api.js';
 import { graphql } from '@/graphql/graphql.js';

+ 2 - 2
packages/dashboard/src/routes/login.tsx

@@ -1,5 +1,5 @@
 import { useAuth } from '@/providers/auth.js';
-import { LoginForm } from '@/components/login-form';
+import { LoginForm } from '@/components/login-form.js';
 import { createFileRoute, Navigate, redirect, useRouterState } from '@tanstack/react-router';
 import * as React from 'react';
 import { z } from 'zod';
@@ -37,7 +37,7 @@ export default function LoginPage() {
 
     const isVerifying = isLoading || auth.status === 'verifying';
     return (
-        <div className="flex min-h-svh flex-col items-center justify-center bg-muted p-6 md:p-10">
+        <div className="flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
             <div className="w-full max-w-sm md:max-w-3xl">
                 <LoginForm
                     onFormSubmit={onFormSubmit}

+ 109 - 109
packages/dashboard/src/styles.css

@@ -1,124 +1,124 @@
-@import "tailwindcss";
+@import 'tailwindcss';
 @import './tailwindcss-animate.css';
 
 @custom-variant dark (&:is(.dark *));
 
 :root {
-  --background: hsl(0 0% 100%);
-  --foreground: hsl(0 0% 3.9%);
-  --card: hsl(0 0% 100%);
-  --card-foreground: hsl(0 0% 3.9%);
-  --popover: hsl(0 0% 100%);
-  --popover-foreground: hsl(0 0% 3.9%);
-  --primary: hsl(0 0% 9%);
-  --primary-foreground: hsl(0 0% 98%);
-  --secondary: hsl(0 0% 96.1%);
-  --secondary-foreground: hsl(0 0% 9%);
-  --muted: hsl(0 0% 96.1%);
-  --muted-foreground: hsl(0 0% 45.1%);
-  --accent: hsl(0 0% 96.1%);
-  --accent-foreground: hsl(0 0% 9%);
-  --destructive: hsl(0 84.2% 60.2%);
-  --destructive-foreground: hsl(0 0% 98%);
-  --border: hsl(0 0% 89.8%);
-  --input: hsl(0 0% 89.8%);
-  --ring: hsl(0 0% 3.9%);
-  --chart-1: hsl(12 76% 61%);
-  --chart-2: hsl(173 58% 39%);
-  --chart-3: hsl(197 37% 24%);
-  --chart-4: hsl(43 74% 66%);
-  --chart-5: hsl(27 87% 67%);
-  --radius: 0.6rem;
-  --sidebar: hsl(0 0% 98%);
-  --sidebar-foreground: hsl(240 5.3% 26.1%);
-  --sidebar-primary: hsl(240 5.9% 10%);
-  --sidebar-primary-foreground: hsl(0 0% 98%);
-  --sidebar-accent: hsl(0, 0%, 92%);
-  --sidebar-accent-foreground: hsl(240 5.9% 10%);
-  --sidebar-border: hsl(220 13% 91%);
-  --sidebar-ring: hsl(217.2 91.2% 59.8%);
+    --background: hsl(0 0% 100%);
+    --foreground: hsl(0 0% 3.9%);
+    --card: hsl(0 0% 100%);
+    --card-foreground: hsl(0 0% 3.9%);
+    --popover: hsl(0 0% 100%);
+    --popover-foreground: hsl(0 0% 3.9%);
+    --primary: hsl(0 0% 9%);
+    --primary-foreground: hsl(0 0% 98%);
+    --secondary: hsl(0 0% 96.1%);
+    --secondary-foreground: hsl(0 0% 9%);
+    --muted: hsl(0 0% 96.1%);
+    --muted-foreground: hsl(0 0% 45.1%);
+    --accent: hsl(0 0% 96.1%);
+    --accent-foreground: hsl(0 0% 9%);
+    --destructive: hsl(0 84.2% 60.2%);
+    --destructive-foreground: hsl(0 0% 98%);
+    --border: hsl(0 0% 89.8%);
+    --input: hsl(0 0% 89.8%);
+    --ring: hsl(0 0% 3.9%);
+    --chart-1: hsl(12 76% 61%);
+    --chart-2: hsl(173 58% 39%);
+    --chart-3: hsl(197 37% 24%);
+    --chart-4: hsl(43 74% 66%);
+    --chart-5: hsl(27 87% 67%);
+    --radius: 0.6rem;
+    --sidebar: hsl(0 0% 98%);
+    --sidebar-foreground: hsl(240 5.3% 26.1%);
+    --sidebar-primary: hsl(240 5.9% 10%);
+    --sidebar-primary-foreground: hsl(0 0% 98%);
+    --sidebar-accent: hsl(0, 0%, 92%);
+    --sidebar-accent-foreground: hsl(240 5.9% 10%);
+    --sidebar-border: hsl(220 13% 91%);
+    --sidebar-ring: hsl(217.2 91.2% 59.8%);
 }
 
 .dark {
-  --background: hsl(0 0% 3.9%);
-  --foreground: hsl(0 0% 98%);
-  --card: hsl(0 0% 3.9%);
-  --card-foreground: hsl(0 0% 98%);
-  --popover: hsl(0 0% 3.9%);
-  --popover-foreground: hsl(0 0% 98%);
-  --primary: hsl(0 0% 98%);
-  --primary-foreground: hsl(0 0% 9%);
-  --secondary: hsl(0 0% 14.9%);
-  --secondary-foreground: hsl(0 0% 98%);
-  --muted: hsl(0 0% 14.9%);
-  --muted-foreground: hsl(0 0% 63.9%);
-  --accent: hsl(0 0% 14.9%);
-  --accent-foreground: hsl(0 0% 98%);
-  --destructive: hsl(0 62.8% 30.6%);
-  --destructive-foreground: hsl(0 0% 98%);
-  --border: hsl(0 0% 14.9%);
-  --input: hsl(0 0% 14.9%);
-  --ring: hsl(0 0% 83.1%);
-  --chart-1: hsl(220 70% 50%);
-  --chart-2: hsl(160 60% 45%);
-  --chart-3: hsl(30 80% 55%);
-  --chart-4: hsl(280 65% 60%);
-  --chart-5: hsl(340 75% 55%);
-  --sidebar: hsl(240 5.9% 10%);
-  --sidebar-foreground: hsl(240 4.8% 95.9%);
-  --sidebar-primary: hsl(224.3 76.3% 48%);
-  --sidebar-primary-foreground: hsl(0 0% 100%);
-  --sidebar-accent: hsl(240 3.7% 15.9%);
-  --sidebar-accent-foreground: hsl(240 4.8% 95.9%);
-  --sidebar-border: hsl(240 3.7% 15.9%);
-  --sidebar-ring: hsl(217.2 91.2% 59.8%);
+    --background: hsl(0 0% 3.9%);
+    --foreground: hsl(0 0% 98%);
+    --card: hsl(0 0% 3.9%);
+    --card-foreground: hsl(0 0% 98%);
+    --popover: hsl(0 0% 3.9%);
+    --popover-foreground: hsl(0 0% 98%);
+    --primary: hsl(0 0% 98%);
+    --primary-foreground: hsl(0 0% 9%);
+    --secondary: hsl(0 0% 14.9%);
+    --secondary-foreground: hsl(0 0% 98%);
+    --muted: hsl(0 0% 14.9%);
+    --muted-foreground: hsl(0 0% 63.9%);
+    --accent: hsl(0 0% 14.9%);
+    --accent-foreground: hsl(0 0% 98%);
+    --destructive: hsl(0 62.8% 30.6%);
+    --destructive-foreground: hsl(0 0% 98%);
+    --border: hsl(0 0% 14.9%);
+    --input: hsl(0 0% 14.9%);
+    --ring: hsl(0 0% 83.1%);
+    --chart-1: hsl(220 70% 50%);
+    --chart-2: hsl(160 60% 45%);
+    --chart-3: hsl(30 80% 55%);
+    --chart-4: hsl(280 65% 60%);
+    --chart-5: hsl(340 75% 55%);
+    --sidebar: hsl(240 5.9% 10%);
+    --sidebar-foreground: hsl(240 4.8% 95.9%);
+    --sidebar-primary: hsl(224.3 76.3% 48%);
+    --sidebar-primary-foreground: hsl(0 0% 100%);
+    --sidebar-accent: hsl(240 3.7% 15.9%);
+    --sidebar-accent-foreground: hsl(240 4.8% 95.9%);
+    --sidebar-border: hsl(240 3.7% 15.9%);
+    --sidebar-ring: hsl(217.2 91.2% 59.8%);
 }
 
 @theme inline {
-  --color-background: var(--background);
-  --color-foreground: var(--foreground);
-  --color-card: var(--card);
-  --color-card-foreground: var(--card-foreground);
-  --color-popover: var(--popover);
-  --color-popover-foreground: var(--popover-foreground);
-  --color-primary: var(--primary);
-  --color-primary-foreground: var(--primary-foreground);
-  --color-secondary: var(--secondary);
-  --color-secondary-foreground: var(--secondary-foreground);
-  --color-muted: var(--muted);
-  --color-muted-foreground: var(--muted-foreground);
-  --color-accent: var(--accent);
-  --color-accent-foreground: var(--accent-foreground);
-  --color-destructive: var(--destructive);
-  --color-destructive-foreground: var(--destructive-foreground);
-  --color-border: var(--border);
-  --color-input: var(--input);
-  --color-ring: var(--ring);
-  --color-chart-1: var(--chart-1);
-  --color-chart-2: var(--chart-2);
-  --color-chart-3: var(--chart-3);
-  --color-chart-4: var(--chart-4);
-  --color-chart-5: var(--chart-5);
-  --radius-sm: calc(var(--radius) - 4px);
-  --radius-md: calc(var(--radius) - 2px);
-  --radius-lg: var(--radius);
-  --radius-xl: calc(var(--radius) + 4px);
-  --color-sidebar-ring: var(--sidebar-ring);
-  --color-sidebar-border: var(--sidebar-border);
-  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
-  --color-sidebar-accent: var(--sidebar-accent);
-  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
-  --color-sidebar-primary: var(--sidebar-primary);
-  --color-sidebar-foreground: var(--sidebar-foreground);
-  --color-sidebar: var(--sidebar);
+    --color-background: var(--background);
+    --color-foreground: var(--foreground);
+    --color-card: var(--card);
+    --color-card-foreground: var(--card-foreground);
+    --color-popover: var(--popover);
+    --color-popover-foreground: var(--popover-foreground);
+    --color-primary: var(--primary);
+    --color-primary-foreground: var(--primary-foreground);
+    --color-secondary: var(--secondary);
+    --color-secondary-foreground: var(--secondary-foreground);
+    --color-muted: var(--muted);
+    --color-muted-foreground: var(--muted-foreground);
+    --color-accent: var(--accent);
+    --color-accent-foreground: var(--accent-foreground);
+    --color-destructive: var(--destructive);
+    --color-destructive-foreground: var(--destructive-foreground);
+    --color-border: var(--border);
+    --color-input: var(--input);
+    --color-ring: var(--ring);
+    --color-chart-1: var(--chart-1);
+    --color-chart-2: var(--chart-2);
+    --color-chart-3: var(--chart-3);
+    --color-chart-4: var(--chart-4);
+    --color-chart-5: var(--chart-5);
+    --radius-sm: calc(var(--radius) - 4px);
+    --radius-md: calc(var(--radius) - 2px);
+    --radius-lg: var(--radius);
+    --radius-xl: calc(var(--radius) + 4px);
+    --color-sidebar-ring: var(--sidebar-ring);
+    --color-sidebar-border: var(--sidebar-border);
+    --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+    --color-sidebar-accent: var(--sidebar-accent);
+    --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+    --color-sidebar-primary: var(--sidebar-primary);
+    --color-sidebar-foreground: var(--sidebar-foreground);
+    --color-sidebar: var(--sidebar);
+    --color-brand: #17c1ff;
 }
 
 @layer base {
-  * {
-    @apply border-border outline-ring/50;
-  }
-  body {
-    @apply bg-background text-foreground;
-  }
+    * {
+        @apply border-border outline-ring/50;
+    }
+    body {
+        @apply bg-background text-foreground;
+    }
 }
-

+ 5 - 0
packages/dashboard/src/virtual.d.ts

@@ -5,3 +5,8 @@ declare module 'virtual:admin-api-schema' {
 declare module 'virtual:dashboard-extensions' {
     export const runDashboardExtensions: () => Promise<void>;
 }
+
+declare module 'virtual:vendure-ui-config' {
+    import { AdminUiConfig } from '@vendure/core';
+    export const uiConfig: AdminUiConfig;
+}

+ 59 - 0
packages/dashboard/vite/vite-plugin-ui-config.ts

@@ -0,0 +1,59 @@
+import { AdminUiPlugin, AdminUiPluginOptions } from '@vendure/admin-ui-plugin';
+import { AdminUiConfig, VendureConfig } from '@vendure/core';
+import { getPluginDashboardExtensions } from '@vendure/core';
+import path from 'path';
+import { Plugin } from 'vite';
+
+import { ConfigLoaderApi, getConfigLoaderApi } from './vite-plugin-config-loader.js';
+
+const virtualModuleId = 'virtual:vendure-ui-config';
+const resolvedVirtualModuleId = `\0${virtualModuleId}`;
+
+/**
+ * This Vite plugin scans the configured plugins for any dashboard extensions and dynamically
+ * generates an import statement for each one, wrapped up in a `runDashboardExtensions()`
+ * function which can then be imported and executed in the Dashboard app.
+ */
+export function uiConfigPlugin(): Plugin {
+    let configLoaderApi: ConfigLoaderApi;
+    let vendureConfig: VendureConfig;
+
+    return {
+        name: 'vendure:dashboard-ui-config',
+        configResolved({ plugins }) {
+            configLoaderApi = getConfigLoaderApi(plugins);
+        },
+        resolveId(id) {
+            if (id === virtualModuleId) {
+                return resolvedVirtualModuleId;
+            }
+        },
+        async load(id) {
+            if (id === resolvedVirtualModuleId) {
+                if (!vendureConfig) {
+                    vendureConfig = await configLoaderApi.getVendureConfig();
+                }
+
+                const adminUiPlugin = vendureConfig.plugins?.find(plugin => plugin.name === 'AdminUiPlugin');
+
+                if (!adminUiPlugin) {
+                    throw new Error('AdminUiPlugin not found');
+                }
+
+                const adminUiOptions = adminUiPlugin.options as AdminUiPluginOptions;
+
+                return `
+                    export const uiConfig = ${JSON.stringify(adminUiOptions.adminUiConfig)}
+                `;
+            }
+        },
+    };
+}
+
+/**
+ * Converts an import path to a normalized path relative to the rootDir.
+ */
+function normalizeImportPath(rootDir: string, importPath: string): string {
+    const relativePath = path.relative(rootDir, importPath).replace(/\\/g, '/');
+    return relativePath.replace(/\.tsx?$/, '.js');
+}

+ 2 - 0
packages/dashboard/vite/vite-plugin-vendure-dashboard.ts

@@ -9,6 +9,7 @@ import { adminApiSchemaPlugin } from './vite-plugin-admin-api-schema.js';
 import { configLoaderPlugin } from './vite-plugin-config-loader.js';
 import { dashboardMetadataPlugin } from './vite-plugin-dashboard-metadata.js';
 import { setRootPlugin } from './vite-plugin-set-root.js';
+import { uiConfigPlugin } from './vite-plugin-ui-config.js';
 
 /**
  * @description
@@ -52,6 +53,7 @@ export function vendureDashboardPlugin(options: VitePluginVendureDashboardOption
         setRootPlugin({ packageRoot }),
         adminApiSchemaPlugin(),
         dashboardMetadataPlugin({ rootDir: tempDir }),
+        uiConfigPlugin(),
     ];
 }
 

+ 1 - 0
packages/dev-server/dev-config.ts

@@ -104,6 +104,7 @@ export const devConfig: VendureConfig = {
         AdminUiPlugin.init({
             route: 'admin',
             port: 5001,
+            adminUiConfig: {},
             // Un-comment to compile a custom admin ui
             // app: compileUiExtensions({
             //     outputPath: path.join(__dirname, './custom-admin-ui'),

Some files were not shown because too many files changed in this diff