Просмотр исходного кода

feat(dashboard): Establish basic app shell structure (#3416)

David Höck 10 месяцев назад
Родитель
Сommit
b16d20d183

+ 35 - 7
package-lock.json

@@ -14226,9 +14226,19 @@
       }
     },
     "node_modules/@tanstack/query-core": {
-      "version": "5.66.4",
-      "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.66.4.tgz",
-      "integrity": "sha512-skM/gzNX4shPkqmdTCSoHtJAPMTtmIJNS0hE+xwTTUVYwezArCT34NMermABmBVUg5Ls5aiUXEDXfqwR1oVkcA==",
+      "version": "5.68.0",
+      "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.68.0.tgz",
+      "integrity": "sha512-r8rFYYo8/sY/LNaOqX84h12w7EQev4abFXDWy4UoDVUJzJ5d9Fbmb8ayTi7ScG+V0ap44SF3vNs/45mkzDGyGw==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/tannerlinsley"
+      }
+    },
+    "node_modules/@tanstack/query-devtools": {
+      "version": "5.67.2",
+      "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.67.2.tgz",
+      "integrity": "sha512-O4QXFFd7xqp6EX7sdvc9tsVO8nm4lpWBqwpgjpVLW5g7IeOY6VnS/xvs/YzbRhBVkKTMaJMOUGU7NhSX+YGoNg==",
       "license": "MIT",
       "funding": {
         "type": "github",
@@ -14236,18 +14246,35 @@
       }
     },
     "node_modules/@tanstack/react-query": {
-      "version": "5.66.7",
-      "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.66.7.tgz",
-      "integrity": "sha512-qd3q/tUpF2K1xItfPZddk1k/8pSXnovg41XyCqJgPoyYEirMBtB0sVEVVQ/CsAOngzgWtBPXimVf4q4kM9uO6A==",
+      "version": "5.68.0",
+      "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.68.0.tgz",
+      "integrity": "sha512-mMOdGDKlwTP/WV72QqSNf4PAMeoBp/DqBHQ222wBfb51Looi8QUqnCnb9O98ZgvNISmy6fzxRGBJdZ+9IBvX2Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@tanstack/query-core": "5.68.0"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/tannerlinsley"
+      },
+      "peerDependencies": {
+        "react": "^18 || ^19"
+      }
+    },
+    "node_modules/@tanstack/react-query-devtools": {
+      "version": "5.68.0",
+      "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.68.0.tgz",
+      "integrity": "sha512-h9ArHkfa7SD5eGnJ9h+9M5uYWBdeVeY+WalrtGLCAtJJvHx6/RrtbbzxeoEQbPyx3f0kPcwJ58DGQ+7CBXelpg==",
       "license": "MIT",
       "dependencies": {
-        "@tanstack/query-core": "5.66.4"
+        "@tanstack/query-devtools": "5.67.2"
       },
       "funding": {
         "type": "github",
         "url": "https://github.com/sponsors/tannerlinsley"
       },
       "peerDependencies": {
+        "@tanstack/react-query": "^5.68.0",
         "react": "^18 || ^19"
       }
     },
@@ -45581,6 +45608,7 @@
         "@radix-ui/react-tooltip": "^1.1.8",
         "@tailwindcss/vite": "^4.0.7",
         "@tanstack/react-query": "^5.66.7",
+        "@tanstack/react-query-devtools": "^5.68.0",
         "@tanstack/react-router": "^1.105.0",
         "@tanstack/react-table": "^8.21.2",
         "@types/node": "^22.13.4",

+ 1 - 0
packages/dashboard/package.json

@@ -44,6 +44,7 @@
     "@radix-ui/react-tooltip": "^1.1.8",
     "@tailwindcss/vite": "^4.0.7",
     "@tanstack/react-query": "^5.66.7",
+    "@tanstack/react-query-devtools": "^5.68.0",
     "@tanstack/react-router": "^1.105.0",
     "@tanstack/react-table": "^8.21.2",
     "@types/node": "^22.13.4",

+ 9 - 1
packages/dashboard/src/app-providers.tsx

@@ -6,6 +6,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { createRouter } from '@tanstack/react-router';
 import React from 'react';
 import { UserSettingsProvider } from './providers/user-settings.js';
+import { ThemeProvider } from './providers/theme-provider.js';
+import { ChannelProvider } from './providers/channel-provider.js';
+import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
 
 export const queryClient = new QueryClient();
 
@@ -33,9 +36,14 @@ export function AppProviders({ children }: { children: React.ReactNode }) {
             <QueryClientProvider client={queryClient}>
                 <ServerConfigProvider>
                     <UserSettingsProvider>
-                        <AuthProvider>{children}</AuthProvider>
+                        <ThemeProvider defaultTheme="system">
+                            <AuthProvider>
+                                <ChannelProvider>{children}</ChannelProvider>
+                            </AuthProvider>
+                        </ThemeProvider>
                     </UserSettingsProvider>
                 </ServerConfigProvider>
+                <ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" />
             </QueryClientProvider>
         </I18nProvider>
     );

+ 6 - 50
packages/dashboard/src/components/layout/app-sidebar.tsx

@@ -1,7 +1,5 @@
 import { NavMain } from '@/components/layout/nav-main.js';
-import { NavProjects } from '@/components/layout/nav-projects.js';
 import { NavUser } from '@/components/layout/nav-user.js';
-import { TeamSwitcher } from '@/components/layout/team-switcher.js';
 import {
     Sidebar,
     SidebarContent,
@@ -11,65 +9,23 @@ import {
 } from '@/components/ui/sidebar.js';
 import { getNavMenuConfig } from '@/framework/nav-menu/nav-menu.js';
 import { useDashboardExtensions } from '@/framework/extension-api/use-dashboard-extensions.js';
-import { AudioWaveform, Command, Frame, GalleryVerticalEnd, Map, PieChart } from 'lucide-react';
 import * as React from 'react';
+import { ChannelSwitcher } from './channel-switcher.js';
 
 export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
     const { extensionsLoaded } = useDashboardExtensions();
-    const data = {
-        user: {
-            name: 'shadcn',
-            email: 'm@example.com',
-            avatar: '/avatars/shadcn.jpg',
-        },
-        teams: [
-            {
-                name: 'Acme Inc',
-                logo: GalleryVerticalEnd,
-                plan: 'Enterprise',
-            },
-            {
-                name: 'Acme Corp.',
-                logo: AudioWaveform,
-                plan: 'Startup',
-            },
-            {
-                name: 'Evil Corp.',
-                logo: Command,
-                plan: 'Free',
-            },
-        ],
-        navMain: getNavMenuConfig().sections,
-        projects: [
-            {
-                name: 'Design Engineering',
-                url: '#',
-                icon: Frame,
-            },
-            {
-                name: 'Sales & Marketing',
-                url: '#',
-                icon: PieChart,
-            },
-            {
-                name: 'Travel',
-                url: '#',
-                icon: Map,
-            },
-        ],
-    };
+    const { sections } = getNavMenuConfig();
     return (
         extensionsLoaded && (
             <Sidebar collapsible="icon" {...props}>
                 <SidebarHeader>
-                    <TeamSwitcher teams={data.teams} />
+                    <ChannelSwitcher />
                 </SidebarHeader>
-                <SidebarContent>
-                    <NavMain items={data.navMain} />
-                    <NavProjects projects={data.projects} />
+                <SidebarContent className="flex flex-col h-full overflow-y-auto">
+                    <NavMain items={sections} />
                 </SidebarContent>
                 <SidebarFooter>
-                    <NavUser user={data.user} />
+                    <NavUser />
                 </SidebarFooter>
                 <SidebarRail />
             </Sidebar>

+ 32 - 24
packages/dashboard/src/components/layout/team-switcher.tsx → packages/dashboard/src/components/layout/channel-switcher.tsx

@@ -11,18 +11,16 @@ import {
     DropdownMenuTrigger,
 } from '@/components/ui/dropdown-menu.js';
 import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from '@/components/ui/sidebar.js';
+import { Trans } from '@lingui/react/macro';
+import { useChannel } from '@/providers/channel-provider.js';
+import { Link } from '@tanstack/react-router';
 
-export function TeamSwitcher({
-    teams,
-}: {
-    teams: {
-        name: string;
-        logo: React.ElementType;
-        plan: string;
-    }[];
-}) {
+export function ChannelSwitcher() {
     const { isMobile } = useSidebar();
-    const [activeTeam, setActiveTeam] = React.useState(teams[0]);
+    const { channels, activeChannel, selectedChannel, setSelectedChannel } = useChannel();
+
+    // Use the selected channel if available, otherwise fall back to the active channel
+    const displayChannel = selectedChannel || activeChannel;
 
     return (
         <SidebarMenu>
@@ -34,11 +32,15 @@ export function TeamSwitcher({
                             className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
                         >
                             <div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
-                                <activeTeam.logo className="size-4" />
+                                <span className="truncate font-semibold text-xs">
+                                    {displayChannel?.defaultCurrencyCode}
+                                </span>
                             </div>
                             <div className="grid flex-1 text-left text-sm leading-tight">
-                                <span className="truncate font-semibold">{activeTeam.name}</span>
-                                <span className="truncate text-xs">{activeTeam.plan}</span>
+                                <span className="truncate font-semibold">{displayChannel?.code}</span>
+                                <span className="truncate text-xs">
+                                    Default Language: {displayChannel?.defaultLanguageCode}
+                                </span>
                             </div>
                             <ChevronsUpDown className="ml-auto" />
                         </SidebarMenuButton>
@@ -49,26 +51,32 @@ export function TeamSwitcher({
                         side={isMobile ? 'bottom' : 'right'}
                         sideOffset={4}
                     >
-                        <DropdownMenuLabel className="text-muted-foreground text-xs">Teams</DropdownMenuLabel>
-                        {teams.map((team, index) => (
+                        <DropdownMenuLabel className="text-muted-foreground text-xs">
+                            <Trans>Channels</Trans>
+                        </DropdownMenuLabel>
+                        {channels.map((channel, index) => (
                             <DropdownMenuItem
-                                key={team.name}
-                                onClick={() => setActiveTeam(team)}
+                                key={channel.code}
+                                onClick={() => setSelectedChannel(channel.id)}
                                 className="gap-2 p-2"
                             >
                                 <div className="flex size-6 items-center justify-center rounded-xs border">
-                                    <team.logo className="size-4 shrink-0" />
+                                    <span className="truncate font-semibold text-xs">
+                                        {channel.defaultCurrencyCode}
+                                    </span>
                                 </div>
-                                {team.name}
+                                {channel.code}
                                 <DropdownMenuShortcut>⌘{index + 1}</DropdownMenuShortcut>
                             </DropdownMenuItem>
                         ))}
                         <DropdownMenuSeparator />
-                        <DropdownMenuItem className="gap-2 p-2">
-                            <div className="bg-background flex size-6 items-center justify-center rounded-md border">
-                                <Plus className="size-4" />
-                            </div>
-                            <div className="text-muted-foreground font-medium">Add team</div>
+                        <DropdownMenuItem className="gap-2 p-2 cursor-pointer" asChild>
+                            <Link to={'/channels/new'}>
+                                <div className="bg-background flex size-6 items-center justify-center rounded-md border">
+                                    <Plus className="size-4" />
+                                </div>
+                                <div className="text-muted-foreground font-medium">Add channel</div>
+                            </Link>
                         </DropdownMenuItem>
                     </DropdownMenuContent>
                 </DropdownMenu>

+ 430 - 0
packages/dashboard/src/components/layout/language-dialog.tsx

@@ -0,0 +1,430 @@
+import { Trans } from '@lingui/react/macro';
+import {
+    Dialog,
+    DialogTitle,
+    DialogContent,
+    DialogHeader,
+    DialogTrigger,
+    DialogFooter,
+    DialogClose,
+} from '../ui/dialog.js';
+import { Button } from '../ui/button.js';
+import { FormItem, FormLabel } from '../ui/form.js';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select.js';
+import { Label } from '../ui/label.js';
+import { uiConfig } from 'virtual:vendure-ui-config';
+import { useUserSettings } from '@/providers/user-settings.js';
+
+/**
+ * This is copied from the generated types from @vendure/common/lib/generated-types.d.ts
+ * It is used to provide a list of available currency codes for the user to select from.
+ * esbuild currently does not support import enums.
+ */
+enum CurrencyCode {
+    /** United Arab Emirates dirham */
+    AED = 'AED',
+    /** Afghan afghani */
+    AFN = 'AFN',
+    /** Albanian lek */
+    ALL = 'ALL',
+    /** Armenian dram */
+    AMD = 'AMD',
+    /** Netherlands Antillean guilder */
+    ANG = 'ANG',
+    /** Angolan kwanza */
+    AOA = 'AOA',
+    /** Argentine peso */
+    ARS = 'ARS',
+    /** Australian dollar */
+    AUD = 'AUD',
+    /** Aruban florin */
+    AWG = 'AWG',
+    /** Azerbaijani manat */
+    AZN = 'AZN',
+    /** Bosnia and Herzegovina convertible mark */
+    BAM = 'BAM',
+    /** Barbados dollar */
+    BBD = 'BBD',
+    /** Bangladeshi taka */
+    BDT = 'BDT',
+    /** Bulgarian lev */
+    BGN = 'BGN',
+    /** Bahraini dinar */
+    BHD = 'BHD',
+    /** Burundian franc */
+    BIF = 'BIF',
+    /** Bermudian dollar */
+    BMD = 'BMD',
+    /** Brunei dollar */
+    BND = 'BND',
+    /** Boliviano */
+    BOB = 'BOB',
+    /** Brazilian real */
+    BRL = 'BRL',
+    /** Bahamian dollar */
+    BSD = 'BSD',
+    /** Bhutanese ngultrum */
+    BTN = 'BTN',
+    /** Botswana pula */
+    BWP = 'BWP',
+    /** Belarusian ruble */
+    BYN = 'BYN',
+    /** Belize dollar */
+    BZD = 'BZD',
+    /** Canadian dollar */
+    CAD = 'CAD',
+    /** Congolese franc */
+    CDF = 'CDF',
+    /** Swiss franc */
+    CHF = 'CHF',
+    /** Chilean peso */
+    CLP = 'CLP',
+    /** Renminbi (Chinese) yuan */
+    CNY = 'CNY',
+    /** Colombian peso */
+    COP = 'COP',
+    /** Costa Rican colon */
+    CRC = 'CRC',
+    /** Cuban convertible peso */
+    CUC = 'CUC',
+    /** Cuban peso */
+    CUP = 'CUP',
+    /** Cape Verde escudo */
+    CVE = 'CVE',
+    /** Czech koruna */
+    CZK = 'CZK',
+    /** Djiboutian franc */
+    DJF = 'DJF',
+    /** Danish krone */
+    DKK = 'DKK',
+    /** Dominican peso */
+    DOP = 'DOP',
+    /** Algerian dinar */
+    DZD = 'DZD',
+    /** Egyptian pound */
+    EGP = 'EGP',
+    /** Eritrean nakfa */
+    ERN = 'ERN',
+    /** Ethiopian birr */
+    ETB = 'ETB',
+    /** Euro */
+    EUR = 'EUR',
+    /** Fiji dollar */
+    FJD = 'FJD',
+    /** Falkland Islands pound */
+    FKP = 'FKP',
+    /** Pound sterling */
+    GBP = 'GBP',
+    /** Georgian lari */
+    GEL = 'GEL',
+    /** Ghanaian cedi */
+    GHS = 'GHS',
+    /** Gibraltar pound */
+    GIP = 'GIP',
+    /** Gambian dalasi */
+    GMD = 'GMD',
+    /** Guinean franc */
+    GNF = 'GNF',
+    /** Guatemalan quetzal */
+    GTQ = 'GTQ',
+    /** Guyanese dollar */
+    GYD = 'GYD',
+    /** Hong Kong dollar */
+    HKD = 'HKD',
+    /** Honduran lempira */
+    HNL = 'HNL',
+    /** Croatian kuna */
+    HRK = 'HRK',
+    /** Haitian gourde */
+    HTG = 'HTG',
+    /** Hungarian forint */
+    HUF = 'HUF',
+    /** Indonesian rupiah */
+    IDR = 'IDR',
+    /** Israeli new shekel */
+    ILS = 'ILS',
+    /** Indian rupee */
+    INR = 'INR',
+    /** Iraqi dinar */
+    IQD = 'IQD',
+    /** Iranian rial */
+    IRR = 'IRR',
+    /** Icelandic króna */
+    ISK = 'ISK',
+    /** Jamaican dollar */
+    JMD = 'JMD',
+    /** Jordanian dinar */
+    JOD = 'JOD',
+    /** Japanese yen */
+    JPY = 'JPY',
+    /** Kenyan shilling */
+    KES = 'KES',
+    /** Kyrgyzstani som */
+    KGS = 'KGS',
+    /** Cambodian riel */
+    KHR = 'KHR',
+    /** Comoro franc */
+    KMF = 'KMF',
+    /** North Korean won */
+    KPW = 'KPW',
+    /** South Korean won */
+    KRW = 'KRW',
+    /** Kuwaiti dinar */
+    KWD = 'KWD',
+    /** Cayman Islands dollar */
+    KYD = 'KYD',
+    /** Kazakhstani tenge */
+    KZT = 'KZT',
+    /** Lao kip */
+    LAK = 'LAK',
+    /** Lebanese pound */
+    LBP = 'LBP',
+    /** Sri Lankan rupee */
+    LKR = 'LKR',
+    /** Liberian dollar */
+    LRD = 'LRD',
+    /** Lesotho loti */
+    LSL = 'LSL',
+    /** Libyan dinar */
+    LYD = 'LYD',
+    /** Moroccan dirham */
+    MAD = 'MAD',
+    /** Moldovan leu */
+    MDL = 'MDL',
+    /** Malagasy ariary */
+    MGA = 'MGA',
+    /** Macedonian denar */
+    MKD = 'MKD',
+    /** Myanmar kyat */
+    MMK = 'MMK',
+    /** Mongolian tögrög */
+    MNT = 'MNT',
+    /** Macanese pataca */
+    MOP = 'MOP',
+    /** Mauritanian ouguiya */
+    MRU = 'MRU',
+    /** Mauritian rupee */
+    MUR = 'MUR',
+    /** Maldivian rufiyaa */
+    MVR = 'MVR',
+    /** Malawian kwacha */
+    MWK = 'MWK',
+    /** Mexican peso */
+    MXN = 'MXN',
+    /** Malaysian ringgit */
+    MYR = 'MYR',
+    /** Mozambican metical */
+    MZN = 'MZN',
+    /** Namibian dollar */
+    NAD = 'NAD',
+    /** Nigerian naira */
+    NGN = 'NGN',
+    /** Nicaraguan córdoba */
+    NIO = 'NIO',
+    /** Norwegian krone */
+    NOK = 'NOK',
+    /** Nepalese rupee */
+    NPR = 'NPR',
+    /** New Zealand dollar */
+    NZD = 'NZD',
+    /** Omani rial */
+    OMR = 'OMR',
+    /** Panamanian balboa */
+    PAB = 'PAB',
+    /** Peruvian sol */
+    PEN = 'PEN',
+    /** Papua New Guinean kina */
+    PGK = 'PGK',
+    /** Philippine peso */
+    PHP = 'PHP',
+    /** Pakistani rupee */
+    PKR = 'PKR',
+    /** Polish złoty */
+    PLN = 'PLN',
+    /** Paraguayan guaraní */
+    PYG = 'PYG',
+    /** Qatari riyal */
+    QAR = 'QAR',
+    /** Romanian leu */
+    RON = 'RON',
+    /** Serbian dinar */
+    RSD = 'RSD',
+    /** Russian ruble */
+    RUB = 'RUB',
+    /** Rwandan franc */
+    RWF = 'RWF',
+    /** Saudi riyal */
+    SAR = 'SAR',
+    /** Solomon Islands dollar */
+    SBD = 'SBD',
+    /** Seychelles rupee */
+    SCR = 'SCR',
+    /** Sudanese pound */
+    SDG = 'SDG',
+    /** Swedish krona/kronor */
+    SEK = 'SEK',
+    /** Singapore dollar */
+    SGD = 'SGD',
+    /** Saint Helena pound */
+    SHP = 'SHP',
+    /** Sierra Leonean leone */
+    SLL = 'SLL',
+    /** Somali shilling */
+    SOS = 'SOS',
+    /** Surinamese dollar */
+    SRD = 'SRD',
+    /** South Sudanese pound */
+    SSP = 'SSP',
+    /** São Tomé and Príncipe dobra */
+    STN = 'STN',
+    /** Salvadoran colón */
+    SVC = 'SVC',
+    /** Syrian pound */
+    SYP = 'SYP',
+    /** Swazi lilangeni */
+    SZL = 'SZL',
+    /** Thai baht */
+    THB = 'THB',
+    /** Tajikistani somoni */
+    TJS = 'TJS',
+    /** Turkmenistan manat */
+    TMT = 'TMT',
+    /** Tunisian dinar */
+    TND = 'TND',
+    /** Tongan paʻanga */
+    TOP = 'TOP',
+    /** Turkish lira */
+    TRY = 'TRY',
+    /** Trinidad and Tobago dollar */
+    TTD = 'TTD',
+    /** New Taiwan dollar */
+    TWD = 'TWD',
+    /** Tanzanian shilling */
+    TZS = 'TZS',
+    /** Ukrainian hryvnia */
+    UAH = 'UAH',
+    /** Ugandan shilling */
+    UGX = 'UGX',
+    /** United States dollar */
+    USD = 'USD',
+    /** Uruguayan peso */
+    UYU = 'UYU',
+    /** Uzbekistan som */
+    UZS = 'UZS',
+    /** Venezuelan bolívar soberano */
+    VES = 'VES',
+    /** Vietnamese đồng */
+    VND = 'VND',
+    /** Vanuatu vatu */
+    VUV = 'VUV',
+    /** Samoan tala */
+    WST = 'WST',
+    /** CFA franc BEAC */
+    XAF = 'XAF',
+    /** East Caribbean dollar */
+    XCD = 'XCD',
+    /** CFA franc BCEAO */
+    XOF = 'XOF',
+    /** CFP franc (franc Pacifique) */
+    XPF = 'XPF',
+    /** Yemeni rial */
+    YER = 'YER',
+    /** South African rand */
+    ZAR = 'ZAR',
+    /** Zambian kwacha */
+    ZMW = 'ZMW',
+    /** Zimbabwean dollar */
+    ZWL = 'ZWL',
+}
+
+export function LanguageDialog() {
+    const { availableLocales, availableLanguages } = uiConfig;
+    const { settings, setDisplayLanguage, setDisplayLocale } = useUserSettings();
+    const availableCurrencyCodes = Object.values(CurrencyCode);
+
+    return (
+        <DialogContent>
+            <DialogHeader>
+                <DialogTitle>
+                    <Trans>Select display language</Trans>
+                </DialogTitle>
+            </DialogHeader>
+            <div className="grid grid-cols-2 gap-6">
+                <div className="space-y-1 w-full">
+                    <Label>
+                        <Trans>Display language</Trans>
+                    </Label>
+                    <Select defaultValue={settings.displayLanguage} onValueChange={setDisplayLanguage}>
+                        <SelectTrigger className="w-full">
+                            <SelectValue placeholder="Select a language" />
+                        </SelectTrigger>
+                        <SelectContent>
+                            {availableLanguages.map(language => (
+                                <SelectItem key={language} value={language}>
+                                    {/* TODO: Translate with global helper function */}
+                                    Language {language}
+                                </SelectItem>
+                            ))}
+                        </SelectContent>
+                    </Select>
+                </div>
+                <div className="space-y-1">
+                    <Label>
+                        <Trans>Locale</Trans>
+                    </Label>
+                    <Select defaultValue={settings.displayLocale} onValueChange={setDisplayLocale}>
+                        <SelectTrigger className="w-full">
+                            <SelectValue placeholder="Select a locale" />
+                        </SelectTrigger>
+                        <SelectContent>
+                            {availableLocales.map(locale => (
+                                <SelectItem key={locale} value={locale}>
+                                    {/* TODO: Translate with global helper function */}
+                                    Locale {locale}
+                                </SelectItem>
+                            ))}
+                        </SelectContent>
+                    </Select>
+                </div>
+            </div>
+            <div className="bg-sidebar border border-border rounded-md px-6 py-4 space-y-4">
+                <span className="font-medium block text-accent-foreground">
+                    <Trans>Sample Formatting</Trans>: {settings.displayLocale} {settings.displayLanguage}
+                </span>
+                <Select>
+                    <SelectTrigger>
+                        <SelectValue placeholder="Select a currency" />
+                    </SelectTrigger>
+                    <SelectContent>
+                        {availableCurrencyCodes.map(currency => (
+                            <SelectItem key={currency} value={currency}>
+                                {/* TODO: Translate with global helper function */}
+                                {currency}
+                            </SelectItem>
+                        ))}
+                    </SelectContent>
+                </Select>
+                <div className="flex flex-col">
+                    <span className="text-muted-foreground text-sm font-medium">Medium date</span>
+                    {/* TODO: Format date with global helper function */}
+                    <span>2025-03-14</span>
+                </div>
+                <div className="flex flex-col">
+                    <span className="text-muted-foreground text-sm font-medium">Short date</span>
+                    {/* TODO: Format date with global helper function */}
+                    <span>2025-03-14</span>
+                </div>
+                <div className="flex flex-col">
+                    <span className="text-muted-foreground text-sm font-medium">Price</span>
+                    {/* TODO: Format price with global helper function */}
+                    <span>100.00</span>
+                </div>
+            </div>
+            <DialogFooter>
+                <DialogClose asChild>
+                    <Button>Close</Button>
+                </DialogClose>
+            </DialogFooter>
+        </DialogContent>
+    );
+}

+ 121 - 39
packages/dashboard/src/components/layout/nav-main.tsx

@@ -12,48 +12,130 @@ import {
 import { NavMenuSection } from '@/framework/nav-menu/nav-menu.js';
 import { Link, rootRouteId, useLocation, useMatch } from '@tanstack/react-router';
 import { ChevronRight } from 'lucide-react';
+import * as React from 'react';
 
 export function NavMain({ items }: { items: NavMenuSection[] }) {
     const location = useLocation();
+    // State to track which bottom section is currently open
+    const [openBottomSectionId, setOpenBottomSectionId] = React.useState<string | null>(null);
+
+    // Split sections into top and bottom groups based on placement property
+    const topSections = items.filter(item => item.placement !== 'bottom');
+    const bottomSections = items.filter(item => item.placement === 'bottom');
+
+    // Handle bottom section open/close
+    const handleBottomSectionToggle = (sectionId: string, isOpen: boolean) => {
+        if (isOpen) {
+            setOpenBottomSectionId(sectionId);
+        } else if (openBottomSectionId === sectionId) {
+            setOpenBottomSectionId(null);
+        }
+    };
+
+    // Auto-open the bottom section that contains the current route
+    React.useEffect(() => {
+        const currentPath = location.pathname;
+
+        // Check if the current path is in any bottom section
+        for (const section of bottomSections) {
+            const matchingItem = section.items?.find(
+                item => currentPath === item.url || currentPath.startsWith(`${item.url}/`),
+            );
+
+            if (matchingItem) {
+                setOpenBottomSectionId(section.id);
+                return;
+            }
+        }
+    }, [location.pathname, bottomSections]);
+
+    // Render a top navigation section
+    const renderTopSection = (item: NavMenuSection) => (
+        <Collapsible key={item.title} asChild defaultOpen={item.defaultOpen} className="group/collapsible">
+            <SidebarMenuItem>
+                <CollapsibleTrigger asChild>
+                    <SidebarMenuButton tooltip={item.title}>
+                        {item.icon && <item.icon />}
+                        <span>{item.title}</span>
+                        <ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
+                    </SidebarMenuButton>
+                </CollapsibleTrigger>
+                <CollapsibleContent>
+                    <SidebarMenuSub>
+                        {item.items?.map(subItem => (
+                            <SidebarMenuSubItem key={subItem.title}>
+                                <SidebarMenuSubButton
+                                    asChild
+                                    isActive={
+                                        location.pathname === subItem.url ||
+                                        location.pathname.startsWith(`${subItem.url}/`)
+                                    }
+                                >
+                                    <Link to={subItem.url}>
+                                        <span>{subItem.title}</span>
+                                    </Link>
+                                </SidebarMenuSubButton>
+                            </SidebarMenuSubItem>
+                        ))}
+                    </SidebarMenuSub>
+                </CollapsibleContent>
+            </SidebarMenuItem>
+        </Collapsible>
+    );
+
+    // Render a bottom navigation section with controlled open state
+    const renderBottomSection = (item: NavMenuSection) => (
+        <Collapsible
+            key={item.title}
+            asChild
+            open={openBottomSectionId === item.id}
+            onOpenChange={isOpen => handleBottomSectionToggle(item.id, isOpen)}
+            className="group/collapsible"
+        >
+            <SidebarMenuItem>
+                <CollapsibleTrigger asChild>
+                    <SidebarMenuButton tooltip={item.title}>
+                        {item.icon && <item.icon />}
+                        <span>{item.title}</span>
+                        <ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
+                    </SidebarMenuButton>
+                </CollapsibleTrigger>
+                <CollapsibleContent>
+                    <SidebarMenuSub>
+                        {item.items?.map(subItem => (
+                            <SidebarMenuSubItem key={subItem.title}>
+                                <SidebarMenuSubButton
+                                    asChild
+                                    isActive={
+                                        location.pathname === subItem.url ||
+                                        location.pathname.startsWith(`${subItem.url}/`)
+                                    }
+                                >
+                                    <Link to={subItem.url}>
+                                        <span>{subItem.title}</span>
+                                    </Link>
+                                </SidebarMenuSubButton>
+                            </SidebarMenuSubItem>
+                        ))}
+                    </SidebarMenuSub>
+                </CollapsibleContent>
+            </SidebarMenuItem>
+        </Collapsible>
+    );
+
     return (
-        <SidebarGroup>
-            <SidebarGroupLabel>Platform</SidebarGroupLabel>
-            <SidebarMenu>
-                {items.map(item => (
-                    <Collapsible
-                        key={item.title}
-                        asChild
-                        defaultOpen={item.defaultOpen}
-                        className="group/collapsible"
-                    >
-                        <SidebarMenuItem>
-                            <CollapsibleTrigger asChild>
-                                <SidebarMenuButton tooltip={item.title}>
-                                    {item.icon && <item.icon />}
-                                    <span>{item.title}</span>
-                                    <ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
-                                </SidebarMenuButton>
-                            </CollapsibleTrigger>
-                            <CollapsibleContent>
-                                <SidebarMenuSub>
-                                    {item.items?.map(subItem => (
-                                        <SidebarMenuSubItem key={subItem.title}>
-                                            <SidebarMenuSubButton
-                                                asChild
-                                                isActive={location.pathname === subItem.url}
-                                            >
-                                                <Link to={subItem.url}>
-                                                    <span>{subItem.title}</span>
-                                                </Link>
-                                            </SidebarMenuSubButton>
-                                        </SidebarMenuSubItem>
-                                    ))}
-                                </SidebarMenuSub>
-                            </CollapsibleContent>
-                        </SidebarMenuItem>
-                    </Collapsible>
-                ))}
-            </SidebarMenu>
-        </SidebarGroup>
+        <>
+            {/* Top sections */}
+            <SidebarGroup>
+                <SidebarGroupLabel>Platform</SidebarGroupLabel>
+                <SidebarMenu>{topSections.map(renderTopSection)}</SidebarMenu>
+            </SidebarGroup>
+
+            {/* Bottom sections - will be pushed to the bottom by CSS */}
+            <SidebarGroup className="mt-auto">
+                <SidebarGroupLabel>Administration</SidebarGroupLabel>
+                <SidebarMenu>{bottomSections.map(renderBottomSection)}</SidebarMenu>
+            </SidebarGroup>
+        </>
     );
 }

+ 122 - 71
packages/dashboard/src/components/layout/nav-user.tsx

@@ -2,8 +2,19 @@
 
 import { useAuth } from '@/providers/auth.js';
 import { Route } from '@/routes/_authenticated.js';
-import { useRouter } from '@tanstack/react-router';
-import { BadgeCheck, Bell, ChevronsUpDown, CreditCard, LogOut, Sparkles } from 'lucide-react';
+import { Link, useRouter } from '@tanstack/react-router';
+import {
+    BadgeCheck,
+    Bell,
+    ChevronsUpDown,
+    CreditCard,
+    LogOut,
+    Monitor,
+    Moon,
+    Sparkles,
+    Sun,
+    SunMoon,
+} from 'lucide-react';
 
 import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar.js';
 import {
@@ -14,22 +25,25 @@ import {
     DropdownMenuLabel,
     DropdownMenuSeparator,
     DropdownMenuTrigger,
+    DropdownMenuSub,
+    DropdownMenuSubTrigger,
+    DropdownMenuPortal,
+    DropdownMenuSubContent,
+    DropdownMenuRadioGroup,
+    DropdownMenuRadioItem,
 } from '@/components/ui/dropdown-menu.js';
 import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from '@/components/ui/sidebar.js';
+import { useMemo } from 'react';
+import { useUserSettings } from '@/providers/user-settings.js';
+import { LanguageDialog } from './language-dialog.js';
+import { Dialog, DialogTrigger } from '../ui/dialog.js';
 
-export function NavUser({
-    user,
-}: {
-    user: {
-        name: string;
-        email: string;
-        avatar: string;
-    };
-}) {
+export function NavUser() {
     const { isMobile } = useSidebar();
     const router = useRouter();
     const navigate = Route.useNavigate();
-    const auth = useAuth();
+    const { user, ...auth } = useAuth();
+    const { settings, setTheme } = useUserSettings();
 
     const handleLogout = () => {
         auth.logout(() => {
@@ -39,73 +53,110 @@ export function NavUser({
         });
     };
 
+    if (!user) {
+        return <></>;
+    }
+
+    const avatarFallback = useMemo(() => {
+        return user.firstName.charAt(0) + user.lastName.charAt(0);
+    }, [user]);
+
     return (
         <SidebarMenu>
             <SidebarMenuItem>
-                <DropdownMenu>
-                    <DropdownMenuTrigger asChild>
-                        <SidebarMenuButton
-                            size="lg"
-                            className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
-                        >
-                            <Avatar className="h-8 w-8 rounded-lg">
-                                <AvatarImage src={user.avatar} alt={user.name} />
-                                <AvatarFallback className="rounded-lg">CN</AvatarFallback>
-                            </Avatar>
-                            <div className="grid flex-1 text-left text-sm leading-tight">
-                                <span className="truncate font-semibold">{user.name}</span>
-                                <span className="truncate text-xs">{user.email}</span>
-                            </div>
-                            <ChevronsUpDown className="ml-auto size-4" />
-                        </SidebarMenuButton>
-                    </DropdownMenuTrigger>
-                    <DropdownMenuContent
-                        className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
-                        side={isMobile ? 'bottom' : 'right'}
-                        align="end"
-                        sideOffset={4}
-                    >
-                        <DropdownMenuLabel className="p-0 font-normal">
-                            <div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
+                <Dialog>
+                    <DropdownMenu>
+                        <DropdownMenuTrigger asChild>
+                            <SidebarMenuButton
+                                size="lg"
+                                className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
+                            >
                                 <Avatar className="h-8 w-8 rounded-lg">
-                                    <AvatarImage src={user.avatar} alt={user.name} />
-                                    <AvatarFallback className="rounded-lg">CN</AvatarFallback>
+                                    <AvatarImage src={user.id} alt={user.firstName} />
+                                    <AvatarFallback className="rounded-lg">{avatarFallback}</AvatarFallback>
                                 </Avatar>
                                 <div className="grid flex-1 text-left text-sm leading-tight">
-                                    <span className="truncate font-semibold">{user.name}</span>
-                                    <span className="truncate text-xs">{user.email}</span>
+                                    <span className="truncate font-semibold">
+                                        {user.firstName} {user.lastName}
+                                    </span>
+                                    <span className="truncate text-xs">{user.emailAddress}</span>
                                 </div>
-                            </div>
-                        </DropdownMenuLabel>
-                        <DropdownMenuSeparator />
-                        <DropdownMenuGroup>
-                            <DropdownMenuItem>
-                                <Sparkles />
-                                Upgrade to Pro
-                            </DropdownMenuItem>
-                        </DropdownMenuGroup>
-                        <DropdownMenuSeparator />
-                        <DropdownMenuGroup>
-                            <DropdownMenuItem>
-                                <BadgeCheck />
-                                Account
-                            </DropdownMenuItem>
-                            <DropdownMenuItem>
-                                <CreditCard />
-                                Billing
-                            </DropdownMenuItem>
-                            <DropdownMenuItem>
-                                <Bell />
-                                Notifications
+                                <ChevronsUpDown className="ml-auto size-4" />
+                            </SidebarMenuButton>
+                        </DropdownMenuTrigger>
+                        <DropdownMenuContent
+                            className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
+                            side={isMobile ? 'bottom' : 'right'}
+                            align="end"
+                            sideOffset={4}
+                        >
+                            <DropdownMenuLabel className="p-0 font-normal">
+                                <div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
+                                    <Avatar className="h-8 w-8 rounded-lg">
+                                        <AvatarImage src={user.id} alt={user.firstName} />
+                                        <AvatarFallback className="rounded-lg">
+                                            {avatarFallback}
+                                        </AvatarFallback>
+                                    </Avatar>
+                                    <div className="grid flex-1 text-left text-sm leading-tight">
+                                        <span className="truncate font-semibold">
+                                            {user.firstName} {user.lastName}
+                                        </span>
+                                        <span className="truncate text-xs">{user.emailAddress}</span>
+                                    </div>
+                                </div>
+                            </DropdownMenuLabel>
+                            <DropdownMenuSeparator />
+                            <DropdownMenuGroup>
+                                <DropdownMenuItem asChild>
+                                    <a href="https://vendure.io/pricing" target="_blank">
+                                        <Sparkles />
+                                        Explore Enterprise Edition
+                                    </a>
+                                </DropdownMenuItem>
+                            </DropdownMenuGroup>
+                            <DropdownMenuSeparator />
+                            <DropdownMenuGroup>
+                                <DropdownMenuItem>
+                                    <Link to="/profile">Profile</Link>
+                                </DropdownMenuItem>
+                                <DialogTrigger asChild>
+                                    <DropdownMenuItem>Language</DropdownMenuItem>
+                                </DialogTrigger>
+                                <DropdownMenuSub>
+                                    <DropdownMenuSubTrigger>Theme</DropdownMenuSubTrigger>
+                                    <DropdownMenuPortal>
+                                        <DropdownMenuSubContent>
+                                            <DropdownMenuRadioGroup
+                                                value={settings.theme}
+                                                onValueChange={setTheme}
+                                            >
+                                                <DropdownMenuRadioItem value="light">
+                                                    <Sun />
+                                                    Light
+                                                </DropdownMenuRadioItem>
+                                                <DropdownMenuRadioItem value="dark">
+                                                    <Moon />
+                                                    Dark
+                                                </DropdownMenuRadioItem>
+                                                <DropdownMenuRadioItem value="system">
+                                                    <Monitor />
+                                                    System
+                                                </DropdownMenuRadioItem>
+                                            </DropdownMenuRadioGroup>
+                                        </DropdownMenuSubContent>
+                                    </DropdownMenuPortal>
+                                </DropdownMenuSub>
+                            </DropdownMenuGroup>
+                            <DropdownMenuSeparator />
+                            <DropdownMenuItem onClick={handleLogout}>
+                                <LogOut />
+                                Log out
                             </DropdownMenuItem>
-                        </DropdownMenuGroup>
-                        <DropdownMenuSeparator />
-                        <DropdownMenuItem onClick={handleLogout}>
-                            <LogOut />
-                            Log out
-                        </DropdownMenuItem>
-                    </DropdownMenuContent>
-                </DropdownMenu>
+                        </DropdownMenuContent>
+                    </DropdownMenu>
+                    <LanguageDialog />
+                </Dialog>
             </SidebarMenuItem>
         </SidebarMenu>
     );

+ 7 - 3
packages/dashboard/src/components/ui/select.tsx

@@ -24,14 +24,18 @@ function SelectValue({
 
 function SelectTrigger({
   className,
+  size = "default",
   children,
   ...props
-}: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
+}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
+  size?: "sm" | "default"
+}) {
   return (
     <SelectPrimitive.Trigger
       data-slot="select-trigger"
+      data-size={size}
       className={cn(
-        "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex h-9 w-full items-center justify-between rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&>span]:line-clamp-1",
+        "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
         className
       )}
       {...props}
@@ -55,7 +59,7 @@ function SelectContent({
       <SelectPrimitive.Content
         data-slot="select-content"
         className={cn(
-          "bg-popover text-popover-foreground 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md",
+          "bg-popover text-popover-foreground 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
           position === "popper" &&
             "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
           className

+ 147 - 66
packages/dashboard/src/framework/defaults.ts

@@ -1,5 +1,5 @@
 import { navMenu } from '@/framework/nav-menu/nav-menu.js';
-import { BookOpen, Bot, Settings2, SquareTerminal } from 'lucide-react';
+import { BookOpen, Bot, Settings2, ShoppingCart, SquareTerminal, Users, Mail, Terminal } from 'lucide-react';
 
 navMenu({
     sections: [
@@ -8,6 +8,7 @@ navMenu({
             title: 'Catalog',
             icon: SquareTerminal,
             defaultOpen: true,
+            placement: 'top',
             items: [
                 {
                     id: 'products',
@@ -16,70 +17,150 @@ navMenu({
                 },
             ],
         },
-        // {
-        //     title: 'Models',
-        //     url: '#',
-        //     icon: Bot,
-        //     items: [
-        //         {
-        //             title: 'Genesis',
-        //             url: '#',
-        //         },
-        //         {
-        //             title: 'Explorer',
-        //             url: '#',
-        //         },
-        //         {
-        //             title: 'Quantum',
-        //             url: '#',
-        //         },
-        //     ],
-        // },
-        // {
-        //     title: 'Documentation',
-        //     url: '#',
-        //     icon: BookOpen,
-        //     items: [
-        //         {
-        //             title: 'Introduction',
-        //             url: '#',
-        //         },
-        //         {
-        //             title: 'Get Started',
-        //             url: '#',
-        //         },
-        //         {
-        //             title: 'Tutorials',
-        //             url: '#',
-        //         },
-        //         {
-        //             title: 'Changelog',
-        //             url: '#',
-        //         },
-        //     ],
-        // },
-        // {
-        //     title: 'Settings',
-        //     url: '#',
-        //     icon: Settings2,
-        //     items: [
-        //         {
-        //             title: 'General',
-        //             url: '#',
-        //         },
-        //         {
-        //             title: 'Team',
-        //             url: '#',
-        //         },
-        //         {
-        //             title: 'Billing',
-        //             url: '#',
-        //         },
-        //         {
-        //             title: 'Limits',
-        //             url: '#',
-        //         },
-        //     ],
-        // },
+        {
+            id: 'sales',
+            title: 'Sales',
+            icon: ShoppingCart,
+            defaultOpen: true,
+            placement: 'top',
+            items: [
+                {
+                    id: 'orders',
+                    title: 'Orders',
+                    url: '/orders',
+                },
+            ],
+        },
+        {
+            id: 'customers',
+            title: 'Customers',
+            icon: Users,
+            defaultOpen: false,
+            placement: 'top',
+            items: [
+                {
+                    id: 'customers',
+                    title: 'Customers',
+                    url: '/customers',
+                },
+                {
+                    id: 'customer-groups',
+                    title: 'Customer Groups',
+                    url: '/customer-groups',
+                },
+            ],
+        },
+        {
+            id: 'marketing',
+            title: 'Marketing',
+            icon: Mail,
+            defaultOpen: false,
+            placement: 'top',
+            items: [
+                {
+                    id: 'promotions',
+                    title: 'Promotions',
+                    url: '/promotions',
+                },
+            ],
+        },
+        {
+            id: 'system',
+            title: 'System',
+            icon: Terminal,
+            defaultOpen: false,
+            placement: 'bottom',
+            items: [
+                {
+                    id: 'job-queue',
+                    title: 'Job Queue',
+                    url: '/job-queue',
+                },
+                {
+                    id: 'logs',
+                    title: 'Logs',
+                    url: '/logs',
+                },
+                {
+                    id: 'api-keys',
+                    title: 'API Keys',
+                    url: '/api-keys',
+                },
+                {
+                    id: 'webhooks',
+                    title: 'Webhooks',
+                    url: '/webhooks',
+                },
+            ],
+        },
+        {
+            id: 'settings',
+            title: 'Settings',
+            icon: Settings2,
+            defaultOpen: false,
+            placement: 'bottom',
+            items: [
+                {
+                    id: 'sellers',
+                    title: 'Sellers',
+                    url: '/sellers',
+                },
+                {
+                    id: 'channels',
+                    title: 'Channels',
+                    url: '/channels',
+                },
+                {
+                    id: 'stock-locations',
+                    title: 'Stock Locations',
+                    url: '/stock-locations',
+                },
+                {
+                    id: 'admin-users',
+                    title: 'Admin Users',
+                    url: '/admin-users',
+                },
+                {
+                    id: 'roles',
+                    title: 'Roles',
+                    url: '/roles',
+                },
+                {
+                    id: 'shipping-methods',
+                    title: 'Shipping Methods',
+                    url: '/shipping-methods',
+                },
+                {
+                    id: 'payment-methods',
+                    title: 'Payment Methods',
+                    url: '/payment-methods',
+                },
+                {
+                    id: 'tax-categories',
+                    title: 'Tax Categories',
+                    url: '/tax-categories',
+                },
+                {
+                    id: 'tax-rates',
+                    title: 'Tax Rates',
+                    url: '/tax-rates',
+                },
+                {
+                    id: 'countries',
+                    title: 'Countries',
+                    url: '/countries',
+                },
+                {
+                    id: 'zones',
+                    title: 'Zones',
+                    url: '/zones',
+                },
+                {
+                    id: 'global-settings',
+                    title: 'Global Settings',
+                    url: '/global-settings',
+                },
+            ],
+        },
     ],
 });

+ 4 - 0
packages/dashboard/src/framework/nav-menu/nav-menu.ts

@@ -1,5 +1,8 @@
 import type { LucideIcon } from 'lucide-react';
 
+// Define the placement options for navigation sections
+export type NavMenuSectionPlacement = 'top' | 'bottom';
+
 export interface NavMenuItem {
     id: string;
     title: string;
@@ -12,6 +15,7 @@ export interface NavMenuSection {
     icon?: LucideIcon;
     defaultOpen?: boolean;
     items?: NavMenuItem[];
+    placement?: NavMenuSectionPlacement;
 }
 
 export interface NavMenuConfig {

+ 37 - 7
packages/dashboard/src/providers/auth.tsx

@@ -1,5 +1,5 @@
 import { api } from '@/graphql/api.js';
-import { graphql } from '@/graphql/graphql.js';
+import { ResultOf, graphql } from '@/graphql/graphql.js';
 import { useMutation, useQuery } from '@tanstack/react-query';
 import * as React from 'react';
 
@@ -9,7 +9,7 @@ export interface AuthContext {
     isAuthenticated: boolean;
     login: (username: string, password: string, onSuccess?: () => void) => void;
     logout: (onSuccess?: () => void) => Promise<void>;
-    user: string | null;
+    user: ResultOf<typeof ActiveAdministratorQuery>['activeAdministrator'] | undefined;
 }
 
 const LoginMutation = graphql(`
@@ -41,6 +41,23 @@ const CurrentUserQuery = graphql(`
         me {
             id
             identifier
+            channels {
+                id
+                token
+                code
+                permissions
+            }
+        }
+    }
+`);
+
+const ActiveAdministratorQuery = graphql(`
+    query ActiveAdministrator {
+        activeAdministrator {
+            id
+            firstName
+            lastName
+            emailAddress
         }
     }
 `);
@@ -49,18 +66,22 @@ const AuthContext = React.createContext<AuthContext | null>(null);
 
 export function AuthProvider({ children }: { children: React.ReactNode }) {
     const [status, setStatus] = React.useState<AuthContext['status']>('verifying');
-    const [user, setUser] = React.useState<string | null>(null);
     const [authenticationError, setAuthenticationError] = React.useState<string | undefined>();
     const onLoginSuccessFn = React.useRef<() => void>(() => {});
     const onLogoutSuccessFn = React.useRef<() => void>(() => {});
     const isAuthenticated = status === 'authenticated';
 
-    const { data, isLoading } = useQuery({
+    const { data: currentUserData, isLoading } = useQuery({
         queryKey: ['currentUser'],
         queryFn: () => api.query(CurrentUserQuery),
         retry: false,
     });
 
+    const { data: administratorData, isLoading: isAdministratorLoading } = useQuery({
+        queryKey: ['administrator'],
+        queryFn: () => api.query(ActiveAdministratorQuery),
+    });
+
     const loginMutationFn = api.mutate(LoginMutation);
     const loginMutation = useMutation({
         mutationFn: loginMutationFn,
@@ -104,7 +125,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
 
     React.useEffect(() => {
         if (!isLoading) {
-            if (data?.me?.id) {
+            if (currentUserData?.me?.id) {
                 setStatus('authenticated');
             } else {
                 setStatus('unauthenticated');
@@ -112,10 +133,19 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
         } else {
             setStatus('verifying');
         }
-    }, [isLoading, data]);
+    }, [isLoading, currentUserData]);
 
     return (
-        <AuthContext.Provider value={{ isAuthenticated, authenticationError, status, user, login, logout }}>
+        <AuthContext.Provider
+            value={{
+                isAuthenticated,
+                authenticationError,
+                status,
+                user: administratorData?.activeAdministrator,
+                login,
+                logout,
+            }}
+        >
             {children}
         </AuthContext.Provider>
     );

+ 139 - 0
packages/dashboard/src/providers/channel-provider.tsx

@@ -0,0 +1,139 @@
+import * as React from 'react';
+import { api } from '@/graphql/api.js';
+import { ResultOf, graphql } from '@/graphql/graphql.js';
+import { useQuery } from '@tanstack/react-query';
+
+// Define the channel fragment for reuse
+const channelFragment = graphql(`
+    fragment ChannelInfo on Channel {
+        id
+        code
+        token
+        defaultLanguageCode
+        defaultCurrencyCode
+        pricesIncludeTax
+    }
+`);
+
+// Query to get all available channels
+const ChannelsQuery = graphql(
+    `
+        query Channels {
+            channels {
+                items {
+                    ...ChannelInfo
+                }
+                totalItems
+            }
+        }
+    `,
+    [channelFragment],
+);
+
+// Query to get the active channel
+const ActiveChannelQuery = graphql(
+    `
+        query ActiveChannel {
+            activeChannel {
+                ...ChannelInfo
+            }
+        }
+    `,
+    [channelFragment],
+);
+
+// Define the type for a channel
+type Channel = ResultOf<typeof channelFragment>;
+
+// Define the context interface
+interface ChannelContext {
+    activeChannel: Channel | undefined;
+    channels: Channel[];
+    selectedChannelId: string | undefined;
+    selectedChannel: Channel | undefined;
+    isLoading: boolean;
+    setSelectedChannel: (channelId: string) => void;
+}
+
+// Create the context
+const ChannelContext = React.createContext<ChannelContext | undefined>(undefined);
+
+// Local storage key for the selected channel
+const SELECTED_CHANNEL_KEY = 'vendure-selected-channel';
+
+export function ChannelProvider({ children }: { children: React.ReactNode }) {
+    const [selectedChannelId, setSelectedChannelId] = React.useState<string | undefined>(() => {
+        // Initialize from localStorage if available
+        try {
+            const storedChannelId = localStorage.getItem(SELECTED_CHANNEL_KEY);
+            return storedChannelId || undefined;
+        } catch (e) {
+            console.error('Failed to load selected channel from localStorage', e);
+            return undefined;
+        }
+    });
+
+    // Fetch all available channels
+    const { data: channelsData, isLoading: isChannelsLoading } = useQuery({
+        queryKey: ['channels'],
+        queryFn: () => api.query(ChannelsQuery),
+    });
+
+    // Fetch the active channel
+    const { data: activeChannelData, isLoading: isActiveChannelLoading } = useQuery({
+        queryKey: ['activeChannel'],
+        queryFn: () => api.query(ActiveChannelQuery),
+    });
+
+    // Set the selected channel and update localStorage
+    const setSelectedChannel = React.useCallback((channelId: string) => {
+        try {
+            // Store in localStorage
+            localStorage.setItem(SELECTED_CHANNEL_KEY, channelId);
+            setSelectedChannelId(channelId);
+        } catch (e) {
+            console.error('Failed to set selected channel', e);
+        }
+    }, []);
+
+    // If no selected channel is set but we have an active channel, use that
+    React.useEffect(() => {
+        if (!selectedChannelId && activeChannelData?.activeChannel?.id) {
+            setSelectedChannelId(activeChannelData.activeChannel.id);
+            try {
+                localStorage.setItem(SELECTED_CHANNEL_KEY, activeChannelData.activeChannel.id);
+            } catch (e) {
+                console.error('Failed to store selected channel in localStorage', e);
+            }
+        }
+    }, [selectedChannelId, activeChannelData]);
+
+    const channels = channelsData?.channels.items || [];
+    const activeChannel = activeChannelData?.activeChannel;
+    const isLoading = isChannelsLoading || isActiveChannelLoading;
+
+    // Find the selected channel from the list of channels
+    const selectedChannel = React.useMemo(() => {
+        return channels.find(channel => channel.id === selectedChannelId);
+    }, [channels, selectedChannelId]);
+
+    const contextValue: ChannelContext = {
+        activeChannel,
+        channels,
+        selectedChannelId,
+        selectedChannel,
+        isLoading,
+        setSelectedChannel,
+    };
+
+    return <ChannelContext.Provider value={contextValue}>{children}</ChannelContext.Provider>;
+}
+
+// Hook to use the channel context
+export function useChannel() {
+    const context = React.useContext(ChannelContext);
+    if (context === undefined) {
+        throw new Error('useChannel must be used within a ChannelProvider');
+    }
+    return context;
+}

+ 61 - 0
packages/dashboard/src/providers/theme-provider.tsx

@@ -0,0 +1,61 @@
+import { createContext, useContext, useEffect, useState } from 'react';
+import { useUserSettings } from './user-settings.js';
+
+export type Theme = 'dark' | 'light' | 'system';
+
+type ThemeProviderProps = {
+    children: React.ReactNode;
+    defaultTheme?: Theme;
+    storageKey?: string;
+};
+
+type ThemeProviderState = {
+    theme: Theme;
+    setTheme: (theme: Theme) => void;
+};
+
+const initialState: ThemeProviderState = {
+    theme: 'system',
+    setTheme: () => null,
+};
+
+const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
+
+export function ThemeProvider({ children, defaultTheme = 'system', ...props }: ThemeProviderProps) {
+    const { settings, setTheme } = useUserSettings();
+
+    useEffect(() => {
+        const root = window.document.documentElement;
+
+        root.classList.remove('light', 'dark');
+
+        if (settings.theme === 'system') {
+            const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+
+            root.classList.add(systemTheme);
+            return;
+        }
+
+        root.classList.add(settings.theme);
+    }, [settings.theme]);
+
+    return (
+        <ThemeProviderContext.Provider
+            {...props}
+            value={{
+                theme: settings.theme,
+                setTheme: setTheme,
+            }}
+        >
+            {children}
+        </ThemeProviderContext.Provider>
+    );
+}
+
+export const useTheme = () => {
+    const context = useContext(ThemeProviderContext);
+
+    if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider');
+
+    return context;
+};

+ 61 - 64
packages/dashboard/src/providers/user-settings.tsx

@@ -1,31 +1,32 @@
 import React, { createContext, useContext, useState, useEffect } from 'react';
+import { Theme } from './theme-provider.js';
 
 export interface UserSettings {
-  displayLanguage: string;
-  displayLocale: string | null;
-  contentLanguage: string;
-  theme: string;
-  displayUiExtensionPoints: boolean;
-  mainNavExpanded: boolean;
+    displayLanguage: string;
+    displayLocale?: string;
+    contentLanguage: string;
+    theme: Theme;
+    displayUiExtensionPoints: boolean;
+    mainNavExpanded: boolean;
 }
 
 const defaultSettings: UserSettings = {
-  displayLanguage: 'en',
-  displayLocale: null,
-  contentLanguage: 'en',
-  theme: 'default',
-  displayUiExtensionPoints: false,
-  mainNavExpanded: true,
+    displayLanguage: 'en',
+    displayLocale: undefined,
+    contentLanguage: 'en',
+    theme: 'system',
+    displayUiExtensionPoints: false,
+    mainNavExpanded: true,
 };
 
 interface UserSettingsContextType {
-  settings: UserSettings;
-  setDisplayLanguage: (language: string) => void;
-  setDisplayLocale: (locale: string | null) => void;
-  setContentLanguage: (language: string) => void;
-  setTheme: (theme: string) => void;
-  setDisplayUiExtensionPoints: (display: boolean) => void;
-  setMainNavExpanded: (expanded: boolean) => void;
+    settings: UserSettings;
+    setDisplayLanguage: (language: string) => void;
+    setDisplayLocale: (locale: string | null) => void;
+    setContentLanguage: (language: string) => void;
+    setTheme: (theme: string) => void;
+    setDisplayUiExtensionPoints: (display: boolean) => void;
+    setMainNavExpanded: (expanded: boolean) => void;
 }
 
 const UserSettingsContext = createContext<UserSettingsContextType | undefined>(undefined);
@@ -33,57 +34,53 @@ const UserSettingsContext = createContext<UserSettingsContextType | undefined>(u
 const STORAGE_KEY = 'vendure-user-settings';
 
 export const UserSettingsProvider: React.FC<React.PropsWithChildren<{}>> = ({ children }) => {
-  // Load settings from localStorage or use defaults
-  const loadSettings = (): UserSettings => {
-    try {
-      const storedSettings = localStorage.getItem(STORAGE_KEY);
-      if (storedSettings) {
-        return { ...defaultSettings, ...JSON.parse(storedSettings) };
-      }
-    } catch (e) {
-      console.error('Failed to load user settings from localStorage', e);
-    }
-    return { ...defaultSettings };
-  };
+    // Load settings from localStorage or use defaults
+    const loadSettings = (): UserSettings => {
+        try {
+            const storedSettings = localStorage.getItem(STORAGE_KEY);
+            if (storedSettings) {
+                return { ...defaultSettings, ...JSON.parse(storedSettings) };
+            }
+        } catch (e) {
+            console.error('Failed to load user settings from localStorage', e);
+        }
+        return { ...defaultSettings };
+    };
 
-  const [settings, setSettings] = useState<UserSettings>(loadSettings);
+    const [settings, setSettings] = useState<UserSettings>(loadSettings);
 
-  // Save settings to localStorage whenever they change
-  useEffect(() => {
-    try {
-      localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
-    } catch (e) {
-      console.error('Failed to save user settings to localStorage', e);
-    }
-  }, [settings]);
+    // Save settings to localStorage whenever they change
+    useEffect(() => {
+        try {
+            localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
+        } catch (e) {
+            console.error('Failed to save user settings to localStorage', e);
+        }
+    }, [settings]);
 
-  // Settings updaters
-  const updateSetting = <K extends keyof UserSettings>(key: K, value: UserSettings[K]) => {
-    setSettings(prev => ({ ...prev, [key]: value }));
-  };
+    // Settings updaters
+    const updateSetting = <K extends keyof UserSettings>(key: K, value: UserSettings[K]) => {
+        setSettings(prev => ({ ...prev, [key]: value }));
+    };
 
-  const contextValue: UserSettingsContextType = {
-    settings,
-    setDisplayLanguage: (language) => updateSetting('displayLanguage', language),
-    setDisplayLocale: (locale) => updateSetting('displayLocale', locale),
-    setContentLanguage: (language) => updateSetting('contentLanguage', language),
-    setTheme: (theme) => updateSetting('theme', theme),
-    setDisplayUiExtensionPoints: (display) => updateSetting('displayUiExtensionPoints', display),
-    setMainNavExpanded: (expanded) => updateSetting('mainNavExpanded', expanded),
-  };
+    const contextValue: UserSettingsContextType = {
+        settings,
+        setDisplayLanguage: language => updateSetting('displayLanguage', language),
+        setDisplayLocale: locale => updateSetting('displayLocale', locale),
+        setContentLanguage: language => updateSetting('contentLanguage', language),
+        setTheme: theme => updateSetting('theme', theme),
+        setDisplayUiExtensionPoints: display => updateSetting('displayUiExtensionPoints', display),
+        setMainNavExpanded: expanded => updateSetting('mainNavExpanded', expanded),
+    };
 
-  return (
-    <UserSettingsContext.Provider value={contextValue}>
-      {children}
-    </UserSettingsContext.Provider>
-  );
+    return <UserSettingsContext.Provider value={contextValue}>{children}</UserSettingsContext.Provider>;
 };
 
 // Hook to use the user settings
 export const useUserSettings = () => {
-  const context = useContext(UserSettingsContext);
-  if (context === undefined) {
-    throw new Error('useUserSettings must be used within a UserSettingsProvider');
-  }
-  return context;
-};
+    const context = useContext(UserSettingsContext);
+    if (context === undefined) {
+        throw new Error('useUserSettings must be used within a UserSettingsProvider');
+    }
+    return context;
+};

+ 280 - 0
packages/dashboard/vite/constants.ts

@@ -0,0 +1,280 @@
+import { LanguageCode } from '@vendure/core';
+
+export const defaultLanguage = LanguageCode.en;
+export const defaultLocale = undefined;
+
+export const defaultAvailableLanguages = [
+    LanguageCode.he,
+    LanguageCode.ar,
+    LanguageCode.de,
+    LanguageCode.en,
+    LanguageCode.es,
+    LanguageCode.pl,
+    LanguageCode.zh_Hans,
+    LanguageCode.zh_Hant,
+    LanguageCode.pt_BR,
+    LanguageCode.pt_PT,
+    LanguageCode.cs,
+    LanguageCode.fr,
+    LanguageCode.ru,
+    LanguageCode.uk,
+    LanguageCode.it,
+    LanguageCode.fa,
+    LanguageCode.ne,
+    LanguageCode.hr,
+    LanguageCode.sv,
+    LanguageCode.nb,
+    LanguageCode.tr,
+];
+
+export const defaultAvailableLocales = [
+    'AF',
+    'AL',
+    'DZ',
+    'AS',
+    'AD',
+    'AO',
+    'AI',
+    'AQ',
+    'AG',
+    'AR',
+    'AM',
+    'AW',
+    'AU',
+    'AT',
+    'AZ',
+    'BS',
+    'BH',
+    'BD',
+    'BB',
+    'BY',
+    'BE',
+    'BZ',
+    'BJ',
+    'BM',
+    'BT',
+    'BO',
+    'BQ',
+    'BA',
+    'BW',
+    'BV',
+    'BR',
+    'IO',
+    'BN',
+    'BG',
+    'BF',
+    'BI',
+    'CV',
+    'KH',
+    'CM',
+    'CA',
+    'KY',
+    'CF',
+    'TD',
+    'CL',
+    'CN',
+    'CX',
+    'CC',
+    'CO',
+    'KM',
+    'CD',
+    'CG',
+    'CK',
+    'CR',
+    'HR',
+    'CU',
+    'CW',
+    'CY',
+    'CZ',
+    'CI',
+    'DK',
+    'DJ',
+    'DM',
+    'DO',
+    'EC',
+    'EG',
+    'SV',
+    'GQ',
+    'ER',
+    'EE',
+    'SZ',
+    'ET',
+    'FK',
+    'FO',
+    'FJ',
+    'FI',
+    'FR',
+    'GF',
+    'PF',
+    'TF',
+    'GA',
+    'GM',
+    'GE',
+    'DE',
+    'GH',
+    'GI',
+    'GR',
+    'GL',
+    'GD',
+    'GP',
+    'GU',
+    'GT',
+    'GG',
+    'GN',
+    'GW',
+    'GY',
+    'HT',
+    'HM',
+    'VA',
+    'HN',
+    'HK',
+    'HU',
+    'IS',
+    'IN',
+    'ID',
+    'IR',
+    'IQ',
+    'IE',
+    'IM',
+    'IL',
+    'IT',
+    'JM',
+    'JP',
+    'JE',
+    'JO',
+    'KZ',
+    'KE',
+    'KI',
+    'KP',
+    'KR',
+    'KW',
+    'KG',
+    'LA',
+    'LV',
+    'LB',
+    'LS',
+    'LR',
+    'LY',
+    'LI',
+    'LT',
+    'LU',
+    'MO',
+    'MG',
+    'MW',
+    'MY',
+    'MV',
+    'ML',
+    'MT',
+    'MH',
+    'MQ',
+    'MR',
+    'MU',
+    'YT',
+    'MX',
+    'FM',
+    'MD',
+    'MC',
+    'MN',
+    'ME',
+    'MS',
+    'MA',
+    'MZ',
+    'MM',
+    'NA',
+    'NR',
+    'NP',
+    'NL',
+    'NC',
+    'NZ',
+    'NI',
+    'NE',
+    'NG',
+    'NU',
+    'NF',
+    'MK',
+    'MP',
+    'NO',
+    'OM',
+    'PK',
+    'PW',
+    'PS',
+    'PA',
+    'PG',
+    'PY',
+    'PE',
+    'PH',
+    'PN',
+    'PL',
+    'PT',
+    'PR',
+    'QA',
+    'RO',
+    'RU',
+    'RW',
+    'RE',
+    'BL',
+    'SH',
+    'KN',
+    'LC',
+    'MF',
+    'PM',
+    'VC',
+    'WS',
+    'SM',
+    'ST',
+    'SA',
+    'SN',
+    'RS',
+    'SC',
+    'SL',
+    'SG',
+    'SX',
+    'SK',
+    'SI',
+    'SB',
+    'SO',
+    'ZA',
+    'GS',
+    'SS',
+    'ES',
+    'LK',
+    'SD',
+    'SR',
+    'SJ',
+    'SE',
+    'CH',
+    'SY',
+    'TW',
+    'TJ',
+    'TZ',
+    'TH',
+    'TL',
+    'TG',
+    'TK',
+    'TO',
+    'TT',
+    'TN',
+    'TR',
+    'TM',
+    'TC',
+    'TV',
+    'UG',
+    'UA',
+    'AE',
+    'GB',
+    'UM',
+    'US',
+    'UY',
+    'UZ',
+    'VU',
+    'VE',
+    'VN',
+    'VG',
+    'VI',
+    'WF',
+    'EH',
+    'YE',
+    'ZM',
+    'ZW',
+    'AX',
+];

+ 60 - 0
packages/dashboard/vite/ui-config.ts

@@ -0,0 +1,60 @@
+import {
+    DEFAULT_AUTH_TOKEN_HEADER_KEY,
+    DEFAULT_CHANNEL_TOKEN_KEY,
+    ADMIN_API_PATH,
+} from '@vendure/common/lib/shared-constants.js';
+import { AdminUiConfig } from '@vendure/common/lib/shared-types.js';
+import { VendureConfig } from '@vendure/core';
+
+import { defaultAvailableLocales } from './constants.js';
+import { defaultLocale, defaultLanguage, defaultAvailableLanguages } from './constants.js';
+
+export function getAdminUiConfig(
+    config: VendureConfig,
+    adminUiConfig?: Partial<AdminUiConfig>,
+): AdminUiConfig {
+    const { authOptions, apiOptions } = config;
+
+    const propOrDefault = <Prop extends keyof AdminUiConfig>(
+        prop: Prop,
+        defaultVal: AdminUiConfig[Prop],
+        isArray: boolean = false,
+    ): AdminUiConfig[Prop] => {
+        if (isArray) {
+            const isValidArray = !!adminUiConfig
+                ? !!((adminUiConfig as AdminUiConfig)[prop] as any[])?.length
+                : false;
+
+            return !!adminUiConfig && isValidArray ? (adminUiConfig as AdminUiConfig)[prop] : defaultVal;
+        } else {
+            return adminUiConfig ? (adminUiConfig as AdminUiConfig)[prop] || defaultVal : defaultVal;
+        }
+    };
+
+    return {
+        adminApiPath: propOrDefault('adminApiPath', apiOptions.adminApiPath || ADMIN_API_PATH),
+        apiHost: propOrDefault('apiHost', 'auto'),
+        apiPort: propOrDefault('apiPort', 'auto'),
+        tokenMethod: propOrDefault('tokenMethod', authOptions.tokenMethod === 'bearer' ? 'bearer' : 'cookie'),
+        authTokenHeaderKey: propOrDefault(
+            'authTokenHeaderKey',
+            authOptions.authTokenHeaderKey || DEFAULT_AUTH_TOKEN_HEADER_KEY,
+        ),
+        channelTokenKey: propOrDefault(
+            'channelTokenKey',
+            apiOptions.channelTokenKey || DEFAULT_CHANNEL_TOKEN_KEY,
+        ),
+        defaultLanguage: propOrDefault('defaultLanguage', defaultLanguage),
+        defaultLocale: propOrDefault('defaultLocale', defaultLocale),
+        availableLanguages: propOrDefault('availableLanguages', defaultAvailableLanguages, true),
+        availableLocales: propOrDefault('availableLocales', defaultAvailableLocales, true),
+        brand: adminUiConfig?.brand,
+        hideVendureBranding: propOrDefault(
+            'hideVendureBranding',
+            adminUiConfig?.hideVendureBranding || false,
+        ),
+        hideVersion: propOrDefault('hideVersion', adminUiConfig?.hideVersion || false),
+        loginImageUrl: adminUiConfig?.loginImageUrl,
+        cancellationReasons: propOrDefault('cancellationReasons', undefined),
+    };
+}

+ 12 - 11
packages/dashboard/vite/vite-plugin-ui-config.ts

@@ -4,17 +4,26 @@ import { getPluginDashboardExtensions } from '@vendure/core';
 import path from 'path';
 import { Plugin } from 'vite';
 
+import { getAdminUiConfig } from './ui-config.js';
 import { ConfigLoaderApi, getConfigLoaderApi } from './vite-plugin-config-loader.js';
 
 const virtualModuleId = 'virtual:vendure-ui-config';
 const resolvedVirtualModuleId = `\0${virtualModuleId}`;
 
+export type UiConfigPluginOptions = {
+    /**
+     * @description
+     * The admin UI config to be passed to the Vendure Dashboard.
+     */
+    adminUiConfig?: Partial<AdminUiConfig>;
+};
+
 /**
  * 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 {
+export function uiConfigPlugin({ adminUiConfig }: UiConfigPluginOptions): Plugin {
     let configLoaderApi: ConfigLoaderApi;
     let vendureConfig: VendureConfig;
 
@@ -34,18 +43,10 @@ export function uiConfigPlugin(): Plugin {
                     vendureConfig = await configLoaderApi.getVendureConfig();
                 }
 
-                const adminUiPlugin = vendureConfig.plugins?.find(
-                    plugin => (plugin as Type<any>).name === 'AdminUiPlugin',
-                );
-
-                if (!adminUiPlugin) {
-                    throw new Error('AdminUiPlugin not found');
-                }
-
-                const adminUiOptions = (adminUiPlugin as any).options as AdminUiPluginOptions;
+                const config = getAdminUiConfig(vendureConfig, adminUiConfig);
 
                 return `
-                    export const uiConfig = ${JSON.stringify(adminUiOptions.adminUiConfig)}
+                    export const uiConfig = ${JSON.stringify(config)}
                 `;
             }
         },

+ 5 - 4
packages/dashboard/vite/vite-plugin-vendure-dashboard.ts

@@ -1,6 +1,7 @@
 import { lingui } from '@lingui/vite-plugin';
 import tailwindcss from '@tailwindcss/vite';
 import { TanStackRouterVite } from '@tanstack/router-plugin/vite';
+import { AdminUiConfig } from '@vendure/core';
 import react from '@vitejs/plugin-react';
 import path from 'path';
 import { PluginOption } from 'vite';
@@ -9,13 +10,13 @@ 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';
+import { UiConfigPluginOptions, uiConfigPlugin } from './vite-plugin-ui-config.js';
 
 /**
  * @description
  * Options for the {@link vendureDashboardPlugin} Vite plugin.
  */
-export interface VitePluginVendureDashboardOptions {
+export type VitePluginVendureDashboardOptions = {
     /**
      * @description
      * The path to the Vendure server configuration file.
@@ -27,7 +28,7 @@ export interface VitePluginVendureDashboardOptions {
      * This is only required if the plugin is unable to auto-detect the name of the exported variable.
      */
     vendureConfigExport?: string;
-}
+} & UiConfigPluginOptions;
 
 /**
  * @description
@@ -53,7 +54,7 @@ export function vendureDashboardPlugin(options: VitePluginVendureDashboardOption
         setRootPlugin({ packageRoot }),
         adminApiSchemaPlugin(),
         dashboardMetadataPlugin({ rootDir: tempDir }),
-        uiConfigPlugin(),
+        uiConfigPlugin({ adminUiConfig: options.uiConfig }),
     ];
 }