diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js
deleted file mode 100644
index ef614d2..0000000
--- a/apps/web/eslint.config.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import js from '@eslint/js'
-import globals from 'globals'
-import reactHooks from 'eslint-plugin-react-hooks'
-import reactRefresh from 'eslint-plugin-react-refresh'
-import tseslint from 'typescript-eslint'
-import { defineConfig, globalIgnores } from 'eslint/config'
-
-export default defineConfig([
- globalIgnores(['dist']),
- {
- files: ['**/*.{ts,tsx}'],
- extends: [
- js.configs.recommended,
- tseslint.configs.recommended,
- reactHooks.configs.flat.recommended,
- reactRefresh.configs.vite,
- ],
- languageOptions: {
- globals: globals.browser,
- },
- },
-])
diff --git a/apps/web/i18n.config.json b/apps/web/i18n.config.json
new file mode 100644
index 0000000..cb9d4d1
--- /dev/null
+++ b/apps/web/i18n.config.json
@@ -0,0 +1,7 @@
+{
+ "sourceLocale": "en",
+ "locales": ["en", "zh-Hans"],
+ "catalogPath": "src/locales/{locale}/messages",
+ "include": ["src", "../../packages/blocks/src", "../../packages/ui/src"],
+ "exclude": ["**/*.test.{ts,tsx}", "**/node_modules/**"]
+}
diff --git a/apps/web/index.html b/apps/web/index.html
deleted file mode 100644
index de8cc51..0000000
--- a/apps/web/index.html
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
- vite-monorepo
-
-
-
-
-
-
diff --git a/apps/web/lingui.config.ts b/apps/web/lingui.config.ts
new file mode 100644
index 0000000..165c993
--- /dev/null
+++ b/apps/web/lingui.config.ts
@@ -0,0 +1,4 @@
+import project from "./i18n.config.json" with { type: "json" }
+import { createLinguiConfig } from "@workspace/i18n/lingui-config"
+
+export default createLinguiConfig(project)
diff --git a/apps/web/package.json b/apps/web/package.json
index 203dbac..679bc40 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -4,32 +4,48 @@
"type": "module",
"private": true,
"scripts": {
- "dev": "vite",
- "build": "tsc -b && vite build",
- "lint": "eslint",
+ "dev": "vite dev",
+ "build": "vite build && tsc -b",
"format": "prettier --write \"**/*.{ts,tsx}\"",
- "typecheck": "tsc --noEmit",
- "preview": "vite preview"
+ "i18n": "workspace-i18n",
+ "i18n:compile": "workspace-i18n compile",
+ "i18n:extract": "workspace-i18n extract",
+ "i18n:ui": "workspace-i18n ui",
+ "typecheck": "tsc -b",
+ "preview": "vite preview",
+ "start": "node .output/server/index.mjs"
},
"dependencies": {
+ "@base-ui/react": "^1.6.0",
+ "@base-ui/utils": "^0.3.1",
+ "@floating-ui/utils": "^0.2.12",
+ "@hugeicons/core-free-icons": "^4.2.3",
+ "@hugeicons/react": "^1.1.9",
+ "@iconify/react": "^6.0.2",
+ "@lingui/core": "^6",
+ "@tanstack/react-form": "^1.33.2",
+ "@tanstack/react-hotkeys": "^0.10.0",
+ "@tanstack/react-query": "^5.101.4",
+ "@tanstack/react-router": "^1.170.18",
+ "@tanstack/react-start": "^1.168.32",
+ "@workspace/blocks": "workspace:*",
+ "@workspace/i18n": "workspace:*",
"@workspace/ui": "workspace:*",
"lucide-react": "^1.27.0",
"react": "^19.2.6",
- "react-dom": "^19.2.6"
+ "react-dom": "^19.2.6",
+ "sonner": "^2.0.7"
},
"devDependencies": {
- "@eslint/js": "^10",
"@tailwindcss/vite": "^4",
+ "@testing-library/react": "^16.3.2",
"@types/node": "^24",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^6",
- "eslint": "^10",
- "eslint-plugin-react-hooks": "^7.1.1",
- "eslint-plugin-react-refresh": "^0.5.2",
- "globals": "^17",
+ "nitro": "^3.0.260610-beta",
"typescript": "~6",
- "typescript-eslint": "^8",
- "vite": "^8"
+ "vite": "^8",
+ "vitest": "^4.1.10"
}
}
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 55a08a9..1855548 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -1,19 +1,24 @@
-import { Button } from "@workspace/ui/components/button"
+import { AppLayout } from "@workspace/blocks/layout"
+
+import { chatThreads } from "./data/chats"
+import { primaryNavigationGroups } from "./data/navigation"
+import {
+ fetchMockNotifications,
+ notificationsQueryKey,
+} from "./data/notifications"
+import { DashboardPage } from "./pages/dashboard-page"
export function App() {
return (
-
-
-
-
Project ready!
-
You may now add components and start building.
-
We've already added the button component for you.
-
-
-
- (Press d to toggle dark mode)
-
-
-
+
+
+
)
}
diff --git a/apps/web/src/components/theme-provider.tsx b/apps/web/src/components/theme-provider.tsx
deleted file mode 100644
index 1349a0c..0000000
--- a/apps/web/src/components/theme-provider.tsx
+++ /dev/null
@@ -1,230 +0,0 @@
-/* eslint-disable react-refresh/only-export-components */
-import * as React from "react"
-
-type Theme = "dark" | "light" | "system"
-type ResolvedTheme = "dark" | "light"
-
-type ThemeProviderProps = {
- children: React.ReactNode
- defaultTheme?: Theme
- storageKey?: string
- disableTransitionOnChange?: boolean
-}
-
-type ThemeProviderState = {
- theme: Theme
- setTheme: (theme: Theme) => void
-}
-
-const COLOR_SCHEME_QUERY = "(prefers-color-scheme: dark)"
-const THEME_VALUES: Theme[] = ["dark", "light", "system"]
-
-const ThemeProviderContext = React.createContext<
- ThemeProviderState | undefined
->(undefined)
-
-function isTheme(value: string | null): value is Theme {
- if (value === null) {
- return false
- }
-
- return THEME_VALUES.includes(value as Theme)
-}
-
-function getSystemTheme(): ResolvedTheme {
- if (window.matchMedia(COLOR_SCHEME_QUERY).matches) {
- return "dark"
- }
-
- return "light"
-}
-
-function disableTransitionsTemporarily() {
- const style = document.createElement("style")
- style.appendChild(
- document.createTextNode(
- "*,*::before,*::after{-webkit-transition:none!important;transition:none!important}"
- )
- )
- document.head.appendChild(style)
-
- return () => {
- window.getComputedStyle(document.body)
- requestAnimationFrame(() => {
- requestAnimationFrame(() => {
- style.remove()
- })
- })
- }
-}
-
-function isEditableTarget(target: EventTarget | null) {
- if (!(target instanceof HTMLElement)) {
- return false
- }
-
- if (target.isContentEditable) {
- return true
- }
-
- const editableParent = target.closest(
- "input, textarea, select, [contenteditable='true']"
- )
- if (editableParent) {
- return true
- }
-
- return false
-}
-
-export function ThemeProvider({
- children,
- defaultTheme = "system",
- storageKey = "theme",
- disableTransitionOnChange = true,
- ...props
-}: ThemeProviderProps) {
- const [theme, setThemeState] = React.useState(() => {
- const storedTheme = localStorage.getItem(storageKey)
- if (isTheme(storedTheme)) {
- return storedTheme
- }
-
- return defaultTheme
- })
-
- const setTheme = React.useCallback(
- (nextTheme: Theme) => {
- localStorage.setItem(storageKey, nextTheme)
- setThemeState(nextTheme)
- },
- [storageKey]
- )
-
- const applyTheme = React.useCallback(
- (nextTheme: Theme) => {
- const root = document.documentElement
- const resolvedTheme =
- nextTheme === "system" ? getSystemTheme() : nextTheme
- const restoreTransitions = disableTransitionOnChange
- ? disableTransitionsTemporarily()
- : null
-
- root.classList.remove("light", "dark")
- root.classList.add(resolvedTheme)
-
- if (restoreTransitions) {
- restoreTransitions()
- }
- },
- [disableTransitionOnChange]
- )
-
- React.useEffect(() => {
- applyTheme(theme)
-
- if (theme !== "system") {
- return undefined
- }
-
- const mediaQuery = window.matchMedia(COLOR_SCHEME_QUERY)
- const handleChange = () => {
- applyTheme("system")
- }
-
- mediaQuery.addEventListener("change", handleChange)
-
- return () => {
- mediaQuery.removeEventListener("change", handleChange)
- }
- }, [theme, applyTheme])
-
- React.useEffect(() => {
- const handleKeyDown = (event: KeyboardEvent) => {
- if (event.repeat) {
- return
- }
-
- if (event.metaKey || event.ctrlKey || event.altKey) {
- return
- }
-
- if (isEditableTarget(event.target)) {
- return
- }
-
- if (event.key.toLowerCase() !== "d") {
- return
- }
-
- setThemeState((currentTheme) => {
- const nextTheme =
- currentTheme === "dark"
- ? "light"
- : currentTheme === "light"
- ? "dark"
- : getSystemTheme() === "dark"
- ? "light"
- : "dark"
-
- localStorage.setItem(storageKey, nextTheme)
- return nextTheme
- })
- }
-
- window.addEventListener("keydown", handleKeyDown)
-
- return () => {
- window.removeEventListener("keydown", handleKeyDown)
- }
- }, [storageKey])
-
- React.useEffect(() => {
- const handleStorageChange = (event: StorageEvent) => {
- if (event.storageArea !== localStorage) {
- return
- }
-
- if (event.key !== storageKey) {
- return
- }
-
- if (isTheme(event.newValue)) {
- setThemeState(event.newValue)
- return
- }
-
- setThemeState(defaultTheme)
- }
-
- window.addEventListener("storage", handleStorageChange)
-
- return () => {
- window.removeEventListener("storage", handleStorageChange)
- }
- }, [defaultTheme, storageKey])
-
- const value = React.useMemo(
- () => ({
- theme,
- setTheme,
- }),
- [theme, setTheme]
- )
-
- return (
-
- {children}
-
- )
-}
-
-export const useTheme = () => {
- const context = React.useContext(ThemeProviderContext)
-
- if (context === undefined) {
- throw new Error("useTheme must be used within a ThemeProvider")
- }
-
- return context
-}
diff --git a/apps/web/src/components/toaster.tsx b/apps/web/src/components/toaster.tsx
new file mode 100644
index 0000000..920e777
--- /dev/null
+++ b/apps/web/src/components/toaster.tsx
@@ -0,0 +1,45 @@
+"use client"
+
+import { useUiState } from "@workspace/blocks/appearance"
+import { Toaster as Sonner, type ToasterProps } from "sonner"
+import {
+ CircleCheckIcon,
+ InfoIcon,
+ TriangleAlertIcon,
+ OctagonXIcon,
+ Loader2Icon,
+} from "lucide-react"
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ const [themeMode] = useUiState("theme-mode")
+
+ return (
+ ,
+ info: ,
+ warning: ,
+ error: ,
+ loading: ,
+ }}
+ style={
+ {
+ "--normal-bg": "var(--popover)",
+ "--normal-text": "var(--popover-foreground)",
+ "--normal-border": "var(--border)",
+ "--border-radius": "var(--radius)",
+ } as React.CSSProperties
+ }
+ toastOptions={{
+ classNames: {
+ toast: "cn-toast",
+ },
+ }}
+ {...props}
+ />
+ )
+}
+
+export { Toaster }
diff --git a/apps/web/src/data/chats.ts b/apps/web/src/data/chats.ts
new file mode 100644
index 0000000..6833f5a
--- /dev/null
+++ b/apps/web/src/data/chats.ts
@@ -0,0 +1,46 @@
+import type { ChatThread } from "@workspace/blocks/chats"
+
+export const chatThreads: readonly ChatThread[] = [
+ {
+ avatarUrl:
+ "https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/images/mock/avatar/avatar-1.webp",
+ name: "Jayvion Simon",
+ presence: "offline",
+ },
+ {
+ avatarUrl:
+ "https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/images/mock/avatar/avatar-2.webp",
+ name: "Lucian Obrien",
+ presence: "online",
+ },
+ {
+ avatarUrl:
+ "https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/images/mock/avatar/avatar-3.webp",
+ name: "Deja Brady",
+ },
+ {
+ avatarUrl:
+ "https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/images/mock/avatar/avatar-4.webp",
+ name: "Harrison Stein",
+ },
+ {
+ avatarUrl:
+ "https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/images/mock/avatar/avatar-5.webp",
+ name: "Reece Chung",
+ },
+ {
+ avatarUrl:
+ "https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/images/mock/avatar/avatar-6.webp",
+ name: "Cristopher Cardenas",
+ },
+ {
+ avatarUrl:
+ "https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/images/mock/avatar/avatar-8.webp",
+ name: "Melanie Noble",
+ },
+ {
+ avatarUrl:
+ "https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/images/mock/avatar/avatar-9.webp",
+ name: "Chase Day",
+ },
+]
diff --git a/apps/web/src/data/navigation.ts b/apps/web/src/data/navigation.ts
new file mode 100644
index 0000000..d7f217e
--- /dev/null
+++ b/apps/web/src/data/navigation.ts
@@ -0,0 +1,594 @@
+import {
+ Alert02Icon,
+ Analytics01Icon,
+ AnalyticsUpIcon,
+ AppWindowIcon,
+ ArrowDataTransferHorizontalIcon,
+ BubbleChatIcon,
+ Calculator01Icon,
+ CallMissed01Icon,
+ CancelCircleIcon,
+ Chart01Icon,
+ ChartIncreaseIcon,
+ CheckmarkBadge01Icon,
+ CheckmarkCircle01Icon,
+ ClipboardListIcon,
+ Clock01Icon,
+ CodeSquareIcon,
+ Compass01Icon,
+ CreditCardIcon,
+ CrownIcon,
+ Delete02Icon,
+ DeliveryTruck01Icon,
+ Discount01Icon,
+ File01Icon,
+ GalleryHorizontalIcon,
+ GiftIcon,
+ HistoryIcon,
+ IdentityCardIcon,
+ InboxDownloadIcon,
+ InboxUploadIcon,
+ Invoice03Icon,
+ Key01Icon,
+ LinkCircleIcon,
+ Location01Icon,
+ LockKeyIcon,
+ Medal01Icon,
+ MoneySendSquareIcon,
+ Note01Icon,
+ Package02Icon,
+ PackageIcon,
+ Refresh01Icon,
+ RoadLocation01Icon,
+ SecurityWarningIcon,
+ Settings01Icon,
+ Settings03Icon,
+ Share01Icon,
+ ShoppingBag01Icon,
+ ShoppingCart01Icon,
+ StarCircleIcon,
+ StarIcon,
+ Store01Icon,
+ Structure01Icon,
+ Task01Icon,
+ Ticket01Icon,
+ UserGroupIcon,
+ UserShield01Icon,
+ Wallet01Icon,
+ WalletCardsIcon,
+} from "@hugeicons/core-free-icons"
+
+import type { NavigationGroup } from "@workspace/blocks/navigation"
+
+export const primaryNavigationGroups: readonly NavigationGroup[] = [
+ {
+ id: "overview",
+ label: "概览",
+ items: [
+ {
+ id: "overview-dashboard",
+ to: "/",
+ label: "经营总览",
+ icon: Analytics01Icon,
+ },
+ {
+ id: "overview-realtime",
+ to: "/analytics/realtime",
+ label: "实时监控",
+ icon: AnalyticsUpIcon,
+ },
+ {
+ id: "overview-tasks",
+ to: "/workspace/tasks",
+ label: "待办中心",
+ icon: Task01Icon,
+ },
+ ],
+ },
+ {
+ id: "catalog-and-inventory",
+ label: "商品与库存",
+ items: [
+ {
+ id: "catalog-products",
+ label: "商品中心",
+ icon: ShoppingBag01Icon,
+ items: [
+ {
+ id: "catalog-products-list",
+ to: "/catalog/products/list",
+ label: "商品列表",
+ icon: ShoppingBag01Icon,
+ },
+ {
+ id: "catalog-products-drafts",
+ to: "/catalog/products/drafts",
+ label: "商品草稿",
+ icon: File01Icon,
+ },
+ {
+ id: "catalog-products-bundles",
+ to: "/catalog/products/bundles",
+ label: "组合商品",
+ icon: Package02Icon,
+ },
+ {
+ id: "catalog-products-attributes",
+ label: "商品属性",
+ icon: Settings03Icon,
+ items: [
+ {
+ id: "catalog-product-specifications",
+ to: "/catalog/products/attributes/specifications",
+ label: "规格模板",
+ icon: ClipboardListIcon,
+ },
+ {
+ id: "catalog-product-brands",
+ to: "/catalog/products/attributes/brands",
+ label: "品牌资料",
+ icon: StarIcon,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ id: "catalog-categories",
+ to: "/catalog/categories",
+ label: "分类目录",
+ icon: ClipboardListIcon,
+ items: [
+ {
+ id: "catalog-category-tree",
+ to: "/catalog/categories/tree",
+ label: "分类树",
+ icon: Structure01Icon,
+ },
+ {
+ id: "catalog-category-navigation",
+ to: "/catalog/categories/navigation",
+ label: "导航分类",
+ icon: Compass01Icon,
+ },
+ ],
+ },
+ {
+ id: "inventory-center",
+ to: "/inventory",
+ label: "库存中心",
+ icon: PackageIcon,
+ items: [
+ {
+ id: "inventory-overview",
+ to: "/inventory/overview",
+ label: "库存总览",
+ icon: Chart01Icon,
+ },
+ {
+ id: "inventory-inbound",
+ to: "/inventory/inbound",
+ label: "入库单",
+ icon: InboxDownloadIcon,
+ },
+ {
+ id: "inventory-outbound",
+ to: "/inventory/outbound",
+ label: "出库单",
+ icon: InboxUploadIcon,
+ },
+ {
+ id: "inventory-transfers",
+ to: "/inventory/transfers",
+ label: "调拨单",
+ icon: ArrowDataTransferHorizontalIcon,
+ },
+ {
+ id: "inventory-alerts",
+ to: "/inventory/alerts",
+ label: "库存预警",
+ icon: Alert02Icon,
+ },
+ ],
+ },
+ {
+ id: "content-assets",
+ to: "/content",
+ label: "内容素材",
+ icon: File01Icon,
+ items: [
+ {
+ id: "content-articles",
+ to: "/content/articles",
+ label: "内容文章",
+ icon: File01Icon,
+ },
+ {
+ id: "content-media",
+ to: "/content/media",
+ label: "媒体资源",
+ icon: GalleryHorizontalIcon,
+ },
+ {
+ id: "content-pages",
+ to: "/content/pages",
+ label: "自定义页面",
+ icon: AppWindowIcon,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ id: "trade-and-fulfillment",
+ label: "交易与履约",
+ items: [
+ {
+ id: "trade-orders",
+ to: "/trade/orders",
+ label: "订单中心",
+ icon: ShoppingCart01Icon,
+ items: [
+ {
+ id: "trade-orders-awaiting-payment",
+ to: "/trade/orders/awaiting-payment",
+ label: "待付款订单",
+ icon: WalletCardsIcon,
+ },
+ {
+ id: "trade-orders-awaiting-shipment",
+ to: "/trade/orders/awaiting-shipment",
+ label: "待发货订单",
+ icon: PackageIcon,
+ },
+ {
+ id: "trade-orders-shipped",
+ to: "/trade/orders/shipped",
+ label: "配送中订单",
+ icon: DeliveryTruck01Icon,
+ },
+ {
+ id: "trade-orders-completed",
+ to: "/trade/orders/completed",
+ label: "已完成订单",
+ icon: CheckmarkCircle01Icon,
+ },
+ {
+ id: "trade-orders-canceled",
+ to: "/trade/orders/canceled",
+ label: "已取消订单",
+ icon: CancelCircleIcon,
+ },
+ ],
+ },
+ {
+ id: "fulfillment-center",
+ to: "/fulfillment",
+ label: "配送履约",
+ icon: DeliveryTruck01Icon,
+ items: [
+ {
+ id: "fulfillment-deliveries",
+ to: "/fulfillment/deliveries",
+ label: "配送任务",
+ icon: Location01Icon,
+ },
+ {
+ id: "fulfillment-pickups",
+ to: "/fulfillment/pickups",
+ label: "自提核销",
+ icon: Store01Icon,
+ },
+ {
+ id: "fulfillment-freight-rules",
+ to: "/fulfillment/freight-rules",
+ label: "运费规则",
+ icon: RoadLocation01Icon,
+ },
+ ],
+ },
+ {
+ id: "after-sales-center",
+ to: "/after-sales",
+ label: "售后中心",
+ icon: CallMissed01Icon,
+ items: [
+ {
+ id: "after-sales-returns",
+ to: "/after-sales/returns",
+ label: "退货申请",
+ icon: Refresh01Icon,
+ },
+ {
+ id: "after-sales-refunds",
+ to: "/after-sales/refunds",
+ label: "退款审核",
+ icon: MoneySendSquareIcon,
+ },
+ {
+ id: "after-sales-disputes",
+ to: "/after-sales/disputes",
+ label: "交易纠纷",
+ icon: SecurityWarningIcon,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ id: "customers-and-marketing",
+ label: "客户与营销",
+ items: [
+ {
+ id: "customer-center",
+ to: "/customers",
+ label: "客户中心",
+ icon: IdentityCardIcon,
+ items: [
+ {
+ id: "customer-profiles",
+ to: "/customers/profiles",
+ label: "客户档案",
+ icon: IdentityCardIcon,
+ },
+ {
+ id: "customer-segments",
+ to: "/customers/segments",
+ label: "客户分群",
+ icon: UserGroupIcon,
+ },
+ {
+ id: "customer-tiers",
+ to: "/customers/tiers",
+ label: "客户等级",
+ icon: CrownIcon,
+ },
+ {
+ id: "customer-risk-list",
+ to: "/customers/risk-list",
+ label: "风险名单",
+ icon: SecurityWarningIcon,
+ },
+ ],
+ },
+ {
+ id: "marketing-campaigns",
+ to: "/marketing/campaigns",
+ label: "营销活动",
+ icon: Discount01Icon,
+ items: [
+ {
+ id: "marketing-coupons",
+ to: "/marketing/coupons",
+ label: "优惠券管理",
+ icon: Ticket01Icon,
+ },
+ {
+ id: "marketing-flash-sales",
+ to: "/marketing/flash-sales",
+ label: "限时折扣",
+ icon: Clock01Icon,
+ },
+ {
+ id: "marketing-gift-promotions",
+ to: "/marketing/gift-promotions",
+ label: "满赠活动",
+ icon: GiftIcon,
+ },
+ {
+ id: "marketing-referrals",
+ to: "/marketing/referrals",
+ label: "邀请有礼",
+ icon: Share01Icon,
+ },
+ ],
+ },
+ {
+ id: "loyalty-center",
+ to: "/loyalty",
+ label: "会员权益",
+ icon: Medal01Icon,
+ items: [
+ {
+ id: "loyalty-points",
+ to: "/loyalty/points",
+ label: "积分规则",
+ icon: StarCircleIcon,
+ },
+ {
+ id: "loyalty-stored-value",
+ to: "/loyalty/stored-value",
+ label: "储值账户",
+ icon: Wallet01Icon,
+ },
+ {
+ id: "loyalty-benefits",
+ to: "/loyalty/benefits",
+ label: "权益配置",
+ icon: CheckmarkBadge01Icon,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ id: "data-and-finance",
+ label: "数据与财务",
+ items: [
+ {
+ id: "report-center",
+ to: "/reports",
+ label: "数据报表",
+ icon: Chart01Icon,
+ items: [
+ {
+ id: "report-sales",
+ to: "/reports/sales",
+ label: "销售分析",
+ icon: AnalyticsUpIcon,
+ },
+ {
+ id: "report-products",
+ to: "/reports/products",
+ label: "商品分析",
+ icon: ShoppingBag01Icon,
+ },
+ {
+ id: "report-customers",
+ to: "/reports/customers",
+ label: "客群分析",
+ icon: UserGroupIcon,
+ },
+ {
+ id: "report-channels",
+ to: "/reports/channels",
+ label: "渠道分析",
+ icon: ChartIncreaseIcon,
+ },
+ ],
+ },
+ {
+ id: "finance-center",
+ to: "/finance",
+ label: "财务中心",
+ icon: WalletCardsIcon,
+ items: [
+ {
+ id: "finance-settlements",
+ to: "/finance/settlements",
+ label: "结算账单",
+ icon: Invoice03Icon,
+ },
+ {
+ id: "finance-reconciliation",
+ to: "/finance/reconciliation",
+ label: "对账管理",
+ icon: Calculator01Icon,
+ },
+ {
+ id: "finance-invoices",
+ to: "/finance/invoices",
+ label: "发票管理",
+ icon: File01Icon,
+ },
+ {
+ id: "finance-payouts",
+ to: "/finance/payouts",
+ label: "打款记录",
+ icon: MoneySendSquareIcon,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ id: "system-management",
+ label: "系统管理",
+ items: [
+ {
+ id: "system-members",
+ to: "/system/members",
+ label: "组织成员",
+ icon: UserShield01Icon,
+ items: [
+ {
+ id: "system-members-admins",
+ to: "/system/members/admins",
+ label: "管理员账号",
+ icon: UserShield01Icon,
+ },
+ {
+ id: "system-members-roles",
+ to: "/system/members/roles",
+ label: "角色管理",
+ icon: UserGroupIcon,
+ },
+ {
+ id: "system-members-permissions",
+ to: "/system/members/permissions",
+ label: "权限策略",
+ icon: LockKeyIcon,
+ },
+ ],
+ },
+ {
+ id: "system-notifications",
+ to: "/system/notifications",
+ label: "消息中心",
+ icon: BubbleChatIcon,
+ items: [
+ {
+ id: "system-notification-templates",
+ to: "/system/notifications/templates",
+ label: "通知模板",
+ icon: File01Icon,
+ },
+ {
+ id: "system-notification-logs",
+ to: "/system/notifications/logs",
+ label: "发送记录",
+ icon: HistoryIcon,
+ },
+ ],
+ },
+ {
+ id: "system-audit",
+ to: "/system/audit",
+ label: "操作审计",
+ icon: Note01Icon,
+ },
+ {
+ id: "system-integrations",
+ to: "/system/integrations",
+ label: "开放集成",
+ icon: CodeSquareIcon,
+ items: [
+ {
+ id: "system-integration-api-keys",
+ to: "/system/integrations/api-keys",
+ label: "API 密钥",
+ icon: Key01Icon,
+ },
+ {
+ id: "system-integration-webhooks",
+ to: "/system/integrations/webhooks",
+ label: "Webhook 配置",
+ icon: LinkCircleIcon,
+ },
+ ],
+ },
+ {
+ id: "system-settings",
+ to: "/system-configs",
+ label: "系统配置",
+ icon: Settings01Icon,
+ items: [
+ {
+ id: "system-settings-store",
+ to: "/system-configs/store",
+ label: "门店资料",
+ icon: Store01Icon,
+ },
+ {
+ id: "system-settings-payments",
+ to: "/system-configs/payments",
+ label: "支付渠道",
+ icon: CreditCardIcon,
+ },
+ {
+ id: "system-settings-shipping",
+ to: "/system-configs/shipping",
+ label: "配送设置",
+ icon: DeliveryTruck01Icon,
+ },
+ ],
+ },
+ {
+ id: "system-recycle-bin",
+ to: "/system/recycle-bin",
+ label: "回收站",
+ icon: Delete02Icon,
+ },
+ ],
+ },
+]
diff --git a/apps/web/src/data/notifications.ts b/apps/web/src/data/notifications.ts
new file mode 100644
index 0000000..8a9b972
--- /dev/null
+++ b/apps/web/src/data/notifications.ts
@@ -0,0 +1,226 @@
+import {
+ DeliveryTruck01Icon,
+ Mail01Icon,
+ Message01Icon,
+ PackageIcon,
+} from "@hugeicons/core-free-icons"
+import type { QueryFunctionContext } from "@tanstack/react-query"
+
+import type { Notification } from "@workspace/blocks/notifications"
+
+const RANDOM_USER_API_URL =
+ "https://randomuser.me/api/1.4/?results=4&inc=name,email,picture&nat=au,ca,de,fr,gb,nl,nz,us&noinfo"
+
+interface RandomUser {
+ email: string
+ name: {
+ first: string
+ last: string
+ }
+ picture: {
+ medium: string
+ }
+}
+
+const HOUR_IN_MILLISECONDS = 60 * 60 * 1000
+
+function getCreatedAt(hoursAgo: number) {
+ return new Date(Date.now() - hoursAgo * HOUR_IN_MILLISECONDS).toISOString()
+}
+
+export const fallbackNotifications: readonly Notification[] = [
+ {
+ id: "friend-request-deja-brady",
+ title: "Deja Brady sent you a friend request",
+ createdAt: getCreatedAt(11),
+ category: "Communication",
+ isRead: false,
+ media: {
+ kind: "avatar",
+ src: "https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/images/mock/avatar/avatar-2.webp",
+ alt: "Deja Brady",
+ },
+ actions: [
+ { id: "accept", label: "Accept" },
+ { id: "decline", label: "Decline", intent: "neutral" },
+ ],
+ },
+ {
+ id: "mention-minimal-ui",
+ title: "Jayvon Hull mentioned you in Minimal UI",
+ createdAt: getCreatedAt(24),
+ category: "Project UI",
+ to: "/workspace/tasks",
+ isRead: false,
+ media: {
+ kind: "avatar",
+ src: "https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/images/mock/avatar/avatar-3.webp",
+ alt: "Jayvon Hull",
+ },
+ actions: [{ id: "view", label: "View", intent: "neutral" }],
+ },
+ {
+ id: "order-placed",
+ title: "Your order is placed",
+ description: "The order is confirmed and waiting for shipping.",
+ createdAt: getCreatedAt(24 * 6),
+ category: "Order",
+ isRead: true,
+ media: {
+ kind: "icon",
+ icon: PackageIcon,
+ tone: "warning",
+ },
+ },
+ {
+ id: "order-shipping",
+ title: "Delivery in progress",
+ description: "Your order has left the warehouse and is being shipped.",
+ createdAt: getCreatedAt(24 * 7),
+ category: "Order",
+ isRead: true,
+ media: {
+ kind: "icon",
+ icon: DeliveryTruck01Icon,
+ tone: "success",
+ },
+ },
+ {
+ id: "unread-messages",
+ title: "You have 5 unread messages",
+ createdAt: getCreatedAt(24 * 8),
+ category: "Communication",
+ to: "/workspace/tasks",
+ isRead: true,
+ media: {
+ kind: "icon",
+ icon: Message01Icon,
+ tone: "info",
+ },
+ },
+ {
+ id: "new-mail",
+ title: "You have new mail",
+ createdAt: getCreatedAt(24 * 9),
+ category: "Communication",
+ isRead: true,
+ media: {
+ kind: "icon",
+ icon: Mail01Icon,
+ tone: "destructive",
+ },
+ },
+]
+
+export const notificationsQueryKey = ["notifications"] as const
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null
+}
+
+function isRandomUser(value: unknown): value is RandomUser {
+ if (!isRecord(value) || !isRecord(value.name) || !isRecord(value.picture)) {
+ return false
+ }
+
+ return (
+ typeof value.email === "string" &&
+ typeof value.name.first === "string" &&
+ typeof value.name.last === "string" &&
+ typeof value.picture.medium === "string"
+ )
+}
+
+function getUserName(user: RandomUser) {
+ return `${user.name.first} ${user.name.last}`
+}
+
+function getAvatarMedia(user: RandomUser) {
+ return {
+ alt: getUserName(user),
+ kind: "avatar" as const,
+ src: user.picture.medium,
+ }
+}
+
+function createRandomUserNotifications(
+ users: readonly RandomUser[]
+): readonly Notification[] {
+ const [friend, mention, commenter, collaborator] = users
+
+ if (!friend || !mention || !commenter || !collaborator) {
+ throw new Error("Random User API returned too few users")
+ }
+
+ return [
+ {
+ id: `friend-request:${friend.email}`,
+ title: `${getUserName(friend)} sent you a friend request`,
+ createdAt: getCreatedAt(11),
+ category: "Communication",
+ isRead: false,
+ media: getAvatarMedia(friend),
+ actions: [
+ { id: "accept", label: "Accept" },
+ { id: "decline", label: "Decline", intent: "neutral" },
+ ],
+ },
+ {
+ id: `mention:${mention.email}`,
+ title: `${getUserName(mention)} mentioned you in Minimal UI`,
+ createdAt: getCreatedAt(24),
+ category: "Project UI",
+ to: "/workspace/tasks",
+ isRead: false,
+ media: getAvatarMedia(mention),
+ actions: [{ id: "view", label: "View", intent: "neutral" }],
+ },
+ {
+ id: `comment:${commenter.email}`,
+ title: `${getUserName(commenter)} commented on your task`,
+ description: "Can you review the latest changes?",
+ createdAt: getCreatedAt(24 * 2),
+ category: "Project UI",
+ to: "/workspace/tasks",
+ isRead: false,
+ media: getAvatarMedia(commenter),
+ },
+ {
+ id: `collaborator:${collaborator.email}`,
+ title: `${getUserName(collaborator)} joined your workspace`,
+ createdAt: getCreatedAt(24 * 4),
+ category: "Team",
+ isRead: true,
+ media: getAvatarMedia(collaborator),
+ },
+ ...fallbackNotifications.slice(2),
+ ]
+}
+
+export async function fetchMockNotifications({
+ signal,
+}: QueryFunctionContext): Promise {
+ try {
+ const response = await fetch(RANDOM_USER_API_URL, { signal })
+
+ if (!response.ok) {
+ throw new Error(`Random User API responded with ${response.status}`)
+ }
+
+ const payload: unknown = await response.json()
+
+ if (!isRecord(payload) || !Array.isArray(payload.results)) {
+ throw new Error("Random User API returned an invalid response")
+ }
+
+ const users = payload.results.filter(isRandomUser)
+
+ return createRandomUserNotifications(users)
+ } catch (error) {
+ if (signal.aborted) {
+ throw error
+ }
+
+ return fallbackNotifications
+ }
+}
diff --git a/apps/web/src/i18n/app-i18n-provider.tsx b/apps/web/src/i18n/app-i18n-provider.tsx
new file mode 100644
index 0000000..d3f2755
--- /dev/null
+++ b/apps/web/src/i18n/app-i18n-provider.tsx
@@ -0,0 +1,51 @@
+import { lazy, Suspense, type PropsWithChildren } from "react"
+import { I18nProvider, type LocaleDefinition } from "@workspace/i18n"
+
+import { messages as englishMessages } from "@/locales/en/messages"
+import { messages as simplifiedChineseMessages } from "@/locales/zh-Hans/messages"
+
+const I18nDevtool = import.meta.env.DEV
+ ? lazy(() =>
+ import("@workspace/i18n/devtool").then((module) => ({
+ default: module.I18nDevtool,
+ }))
+ )
+ : null
+
+export const appLocales = [
+ {
+ label: "English",
+ locale: "en",
+ },
+ {
+ label: "简体中文",
+ locale: "zh-Hans",
+ },
+] as const satisfies readonly LocaleDefinition[]
+
+export type AppLocale = (typeof appLocales)[number]["locale"]
+
+const appCatalogs = {
+ en: englishMessages,
+ "zh-Hans": simplifiedChineseMessages,
+}
+
+export interface AppI18nProviderProps extends PropsWithChildren {
+ locale?: AppLocale
+}
+
+export function AppI18nProvider({
+ children,
+ locale = "zh-Hans",
+}: AppI18nProviderProps) {
+ return (
+
+ {children}
+ {I18nDevtool && (
+
+
+
+ )}
+
+ )
+}
diff --git a/apps/web/src/lib/query-client.ts b/apps/web/src/lib/query-client.ts
new file mode 100644
index 0000000..f0cf6df
--- /dev/null
+++ b/apps/web/src/lib/query-client.ts
@@ -0,0 +1,12 @@
+import { QueryClient } from "@tanstack/react-query"
+
+export const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 1000 * 60 * 5,
+ gcTime: 1000 * 60 * 10,
+ refetchOnWindowFocus: false,
+ retry: 1,
+ },
+ },
+})
diff --git a/apps/web/src/locales/en/messages.po b/apps/web/src/locales/en/messages.po
new file mode 100644
index 0000000..ed86065
--- /dev/null
+++ b/apps/web/src/locales/en/messages.po
@@ -0,0 +1,14 @@
+msgid ""
+msgstr ""
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"X-Generator: @lingui/cli\n"
+"Language: en\n"
+"Project-Id-Version: \n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: \n"
+"PO-Revision-Date: \n"
+"Last-Translator: \n"
+"Language-Team: \n"
+"Plural-Forms: \n"
diff --git a/apps/web/src/locales/en/messages.ts b/apps/web/src/locales/en/messages.ts
new file mode 100644
index 0000000..c754d7a
--- /dev/null
+++ b/apps/web/src/locales/en/messages.ts
@@ -0,0 +1,2 @@
+/*eslint-disable*/ import type { Messages } from "@lingui/core"
+export const messages = JSON.parse("{}") as Messages
diff --git a/apps/web/src/locales/zh-Hans/messages.po b/apps/web/src/locales/zh-Hans/messages.po
new file mode 100644
index 0000000..0e231a8
--- /dev/null
+++ b/apps/web/src/locales/zh-Hans/messages.po
@@ -0,0 +1,14 @@
+msgid ""
+msgstr ""
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"X-Generator: @lingui/cli\n"
+"Language: zh-Hans\n"
+"Project-Id-Version: \n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: \n"
+"PO-Revision-Date: \n"
+"Last-Translator: \n"
+"Language-Team: \n"
+"Plural-Forms: \n"
diff --git a/apps/web/src/locales/zh-Hans/messages.ts b/apps/web/src/locales/zh-Hans/messages.ts
new file mode 100644
index 0000000..c754d7a
--- /dev/null
+++ b/apps/web/src/locales/zh-Hans/messages.ts
@@ -0,0 +1,2 @@
+/*eslint-disable*/ import type { Messages } from "@lingui/core"
+export const messages = JSON.parse("{}") as Messages
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
deleted file mode 100644
index 242ad82..0000000
--- a/apps/web/src/main.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import { StrictMode } from "react"
-import { createRoot } from "react-dom/client"
-
-import "@workspace/ui/globals.css"
-import { App } from "./App.tsx"
-import { ThemeProvider } from "@/components/theme-provider.tsx"
-
-createRoot(document.getElementById("root")!).render(
-
-
-
-
-
-)
diff --git a/apps/web/src/pages/dashboard-page.tsx b/apps/web/src/pages/dashboard-page.tsx
new file mode 100644
index 0000000..5bc367e
--- /dev/null
+++ b/apps/web/src/pages/dashboard-page.tsx
@@ -0,0 +1,48 @@
+import { Button } from "@workspace/ui/components/button"
+import { Slider } from "@workspace/ui/components/slider"
+import { Switch } from "@workspace/ui/components/switch"
+
+export function DashboardPage() {
+ return (
+ <>
+
+
+
+
Project ready!
+
You may now add components and start building.
+
We've already added the button component for you.
+
+
+
+
+
+
+
+ (Press Mod+D to toggle dark mode)
+
+
+
+ breakpoint:
+ mobile
+ tablet
+ desktop
+
+
+ collapsed:
+ true
+ false
+
+
+ >
+ )
+}
diff --git a/apps/web/src/route-tree.gen.ts b/apps/web/src/route-tree.gen.ts
new file mode 100644
index 0000000..e0e762c
--- /dev/null
+++ b/apps/web/src/route-tree.gen.ts
@@ -0,0 +1,109 @@
+/* eslint-disable */
+
+// @ts-nocheck
+
+// noinspection JSUnusedGlobalSymbols
+
+// This file was automatically generated by TanStack Router.
+// You should NOT make any changes in this file as it will be overwritten.
+// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
+
+import { Route as rootRouteImport } from './routes/__root'
+import { Route as AppRouteImport } from './routes/_app'
+import { Route as AppIndexRouteImport } from './routes/_app.index'
+import { Route as AppSplatRouteImport } from './routes/_app.$'
+
+const AppRoute = AppRouteImport.update({
+ id: '/_app',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const AppIndexRoute = AppIndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => AppRoute,
+} as any)
+const AppSplatRoute = AppSplatRouteImport.update({
+ id: '/$',
+ path: '/$',
+ getParentRoute: () => AppRoute,
+} as any)
+
+export interface FileRoutesByFullPath {
+ '/': typeof AppIndexRoute
+ '/$': typeof AppSplatRoute
+}
+export interface FileRoutesByTo {
+ '/$': typeof AppSplatRoute
+ '/': typeof AppIndexRoute
+}
+export interface FileRoutesById {
+ __root__: typeof rootRouteImport
+ '/_app': typeof AppRouteWithChildren
+ '/_app/$': typeof AppSplatRoute
+ '/_app/': typeof AppIndexRoute
+}
+export interface FileRouteTypes {
+ fileRoutesByFullPath: FileRoutesByFullPath
+ fullPaths: '/' | '/$'
+ fileRoutesByTo: FileRoutesByTo
+ to: '/$' | '/'
+ id: '__root__' | '/_app' | '/_app/$' | '/_app/'
+ fileRoutesById: FileRoutesById
+}
+export interface RootRouteChildren {
+ AppRoute: typeof AppRouteWithChildren
+}
+
+declare module '@tanstack/react-router' {
+ interface FileRoutesByPath {
+ '/_app': {
+ id: '/_app'
+ path: ''
+ fullPath: '/'
+ preLoaderRoute: typeof AppRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/_app/': {
+ id: '/_app/'
+ path: '/'
+ fullPath: '/'
+ preLoaderRoute: typeof AppIndexRouteImport
+ parentRoute: typeof AppRoute
+ }
+ '/_app/$': {
+ id: '/_app/$'
+ path: '/$'
+ fullPath: '/$'
+ preLoaderRoute: typeof AppSplatRouteImport
+ parentRoute: typeof AppRoute
+ }
+ }
+}
+
+interface AppRouteChildren {
+ AppSplatRoute: typeof AppSplatRoute
+ AppIndexRoute: typeof AppIndexRoute
+}
+
+const AppRouteChildren: AppRouteChildren = {
+ AppSplatRoute: AppSplatRoute,
+ AppIndexRoute: AppIndexRoute,
+}
+
+const AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren)
+
+const rootRouteChildren: RootRouteChildren = {
+ AppRoute: AppRouteWithChildren,
+}
+export const routeTree = rootRouteImport
+ ._addFileChildren(rootRouteChildren)
+ ._addFileTypes()
+
+import type { getRouter } from './router.tsx'
+import type { createStart } from '@tanstack/react-start'
+declare module '@tanstack/react-start' {
+ interface Register {
+ ssr: true
+ router: Awaited>
+ }
+}
diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx
new file mode 100644
index 0000000..aa1b4fc
--- /dev/null
+++ b/apps/web/src/router.tsx
@@ -0,0 +1,11 @@
+import { createRouter } from "@tanstack/react-router"
+
+import { routeTree } from "./route-tree.gen"
+
+export function getRouter() {
+ return createRouter({
+ routeTree,
+ defaultPreload: "intent",
+ scrollRestoration: true,
+ })
+}
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
new file mode 100644
index 0000000..65b8520
--- /dev/null
+++ b/apps/web/src/routes/__root.tsx
@@ -0,0 +1,146 @@
+import type { ReactNode } from "react"
+import {
+ HeadContent,
+ Outlet,
+ Scripts,
+ createRootRoute,
+} from "@tanstack/react-router"
+import {
+ BreakpointReactor,
+ getBreakpointInitializationScript,
+} from "@workspace/ui/hooks/use-breakpoint"
+
+import appCss from "@/styles/globals.css?url"
+import {
+ AppearanceController,
+ buildThemeStyleSheet,
+ THEME_STYLE_ELEMENT_ID,
+ type UiState,
+ UiStateProvider,
+} from "@workspace/blocks/appearance"
+import { SidebarStateProvider } from "@workspace/blocks/layout"
+import { getUiState, setUiState } from "@/server/ui-state.functions"
+import { HotkeysProvider } from "@tanstack/react-hotkeys"
+import { TooltipProvider } from "@workspace/ui/components/tooltip"
+import { QueryClientProvider } from "@tanstack/react-query"
+import { AppI18nProvider } from "@/i18n/app-i18n-provider"
+import { queryClient } from "@/lib/query-client"
+
+export const Route = createRootRoute({
+ loader: () => getUiState(),
+ head: () => ({
+ meta: [
+ { charSet: "utf-8" },
+ {
+ name: "viewport",
+ content: "width=device-width, initial-scale=1",
+ },
+ { title: "Simple React App Kit" },
+ ],
+ links: [
+ {
+ rel: "stylesheet",
+ href: appCss,
+ },
+ ],
+ }),
+ component: RootComponent,
+})
+
+function RootComponent() {
+ const initialState = Route.useLoaderData()
+
+ return (
+
+
+
+ setUiState({ data: update })}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function getUiInitializationScript(state: UiState) {
+ return `(() => {
+ const theme = ${JSON.stringify(state["theme-mode"])};
+ const resolvedTheme =
+ theme === "system"
+ ? window.matchMedia("(prefers-color-scheme: dark)").matches
+ ? "dark"
+ : "light"
+ : theme;
+ const root = document.documentElement;
+ root.classList.remove("ui:light", "ui:dark");
+ root.classList.add("ui:" + resolvedTheme);
+ root.classList.toggle("ui:compacted", ${state["main-compacted"]});
+ root.classList.toggle("ui:collapsed", ${state["sidebar-collapsed"]});
+ root.style.colorScheme = resolvedTheme;
+ })();`
+}
+
+function RootDocument({
+ children,
+ initialState,
+}: Readonly<{
+ children: ReactNode
+ initialState: UiState
+}>) {
+ const themeMode = initialState["theme-mode"]
+ const className = [
+ themeMode === "system" ? null : `ui:${themeMode}`,
+ initialState["main-compacted"] ? "ui:compacted" : null,
+ initialState["sidebar-collapsed"] ? "ui:collapsed" : null,
+ ]
+ .filter(Boolean)
+ .join(" ")
+
+ return (
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+ )
+}
diff --git a/apps/web/src/routes/_app.$.tsx b/apps/web/src/routes/_app.$.tsx
new file mode 100644
index 0000000..08638f8
--- /dev/null
+++ b/apps/web/src/routes/_app.$.tsx
@@ -0,0 +1,7 @@
+import { createFileRoute } from "@tanstack/react-router"
+
+import { DashboardPage } from "@/pages/dashboard-page"
+
+export const Route = createFileRoute("/_app/$")({
+ component: DashboardPage,
+})
diff --git a/apps/web/src/routes/_app.index.tsx b/apps/web/src/routes/_app.index.tsx
new file mode 100644
index 0000000..b951ff5
--- /dev/null
+++ b/apps/web/src/routes/_app.index.tsx
@@ -0,0 +1,7 @@
+import { createFileRoute } from "@tanstack/react-router"
+
+import { DashboardPage } from "@/pages/dashboard-page"
+
+export const Route = createFileRoute("/_app/")({
+ component: DashboardPage,
+})
diff --git a/apps/web/src/routes/_app.tsx b/apps/web/src/routes/_app.tsx
new file mode 100644
index 0000000..da54f40
--- /dev/null
+++ b/apps/web/src/routes/_app.tsx
@@ -0,0 +1,29 @@
+import { Outlet, createFileRoute } from "@tanstack/react-router"
+
+import { AppLayout } from "@workspace/blocks/layout"
+
+import { chatThreads } from "@/data/chats"
+import { primaryNavigationGroups } from "@/data/navigation"
+import {
+ fetchMockNotifications,
+ notificationsQueryKey,
+} from "@/data/notifications"
+
+export const Route = createFileRoute("/_app")({
+ component: ApplicationLayoutRoute,
+})
+
+function ApplicationLayoutRoute() {
+ return (
+
+
+
+ )
+}
diff --git a/apps/web/src/server/ui-state.functions.ts b/apps/web/src/server/ui-state.functions.ts
new file mode 100644
index 0000000..081f0d3
--- /dev/null
+++ b/apps/web/src/server/ui-state.functions.ts
@@ -0,0 +1,18 @@
+import { createServerFn } from "@tanstack/react-start"
+import { isUiStateUpdate } from "@workspace/blocks/appearance"
+import { readUiState, writeUiState } from "./ui-state.server"
+
+export const getUiState = createServerFn({ method: "GET" }).handler(() => {
+ return readUiState()
+})
+
+export const setUiState = createServerFn({ method: "POST" })
+ .validator((value: unknown) => {
+ if (!isUiStateUpdate(value)) {
+ throw new Error("Invalid UI state update")
+ }
+ return value
+ })
+ .handler(({ data }) => {
+ writeUiState(data.key, data.value)
+ })
diff --git a/apps/web/src/server/ui-state.server.ts b/apps/web/src/server/ui-state.server.ts
new file mode 100644
index 0000000..5ff55e1
--- /dev/null
+++ b/apps/web/src/server/ui-state.server.ts
@@ -0,0 +1,39 @@
+import "@tanstack/react-start/server-only"
+import { getCookie, setCookie } from "@tanstack/react-start/server"
+import {
+ type UiState,
+ type UiStateKey,
+ uiStateDefinitions,
+} from "@workspace/blocks/appearance"
+
+const LEGACY_THEME_COOKIE = "simple-shop-admin-theme"
+
+export function readUiState(): UiState {
+ return {
+ "main-compacted": uiStateDefinitions["main-compacted"].parse(
+ getCookie(uiStateDefinitions["main-compacted"].cookie)
+ ),
+ "sidebar-collapsed": uiStateDefinitions["sidebar-collapsed"].parse(
+ getCookie(uiStateDefinitions["sidebar-collapsed"].cookie)
+ ),
+ "theme-mode": uiStateDefinitions["theme-mode"].parse(
+ getCookie(uiStateDefinitions["theme-mode"].cookie) ??
+ getCookie(LEGACY_THEME_COOKIE)
+ ),
+ "theme-light": uiStateDefinitions["theme-light"].parse(
+ getCookie(uiStateDefinitions["theme-light"].cookie)
+ ),
+ "theme-dark": uiStateDefinitions["theme-dark"].parse(
+ getCookie(uiStateDefinitions["theme-dark"].cookie)
+ ),
+ }
+}
+
+export function writeUiState(key: K, value: UiState[K]) {
+ const definition = uiStateDefinitions[key]
+ setCookie(definition.cookie, definition.serialize(value), {
+ path: "/",
+ sameSite: "lax",
+ maxAge: 60 * 60 * 24 * 365,
+ })
+}
diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css
new file mode 100644
index 0000000..9103d58
--- /dev/null
+++ b/apps/web/src/styles/globals.css
@@ -0,0 +1,48 @@
+@import "@workspace/ui/globals.css";
+@import "@workspace/blocks/globals.css";
+
+:root {
+ --ui-content-top-gap: --spacing(2);
+ --ui-header-height: --spacing(18);
+ --ui-sidebar-collapsed-width: --spacing(22);
+ --ui-sidebar-width: --spacing(74);
+}
+
+.ui\:mobile {
+ --ui-header-height: --spacing(16);
+ --ui-sidebar-width: 0px;
+}
+
+.ui\:tablet,
+.ui\:collapsed:not(.ui\:mobile) {
+ --ui-sidebar-width: var(--ui-sidebar-collapsed-width);
+}
+
+.ui\:theme-changing *,
+.ui\:theme-changing *::before,
+.ui\:theme-changing *::after {
+ -webkit-transition: none !important;
+ transition: none !important;
+}
+
+@keyframes ui-show-scroll-divider {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+@utility scroll-top-divider {
+ opacity: 0;
+}
+
+@supports (animation-timeline: scroll()) {
+ .scroll-top-divider {
+ animation: ui-show-scroll-divider 1s steps(1, end) both;
+ animation-range: 0px var(--ui-content-top-gap, 1px);
+ animation-timeline: scroll(root block);
+ }
+}
diff --git a/apps/web/tsconfig.app.json b/apps/web/tsconfig.app.json
index 03ddd3f..9f5944e 100644
--- a/apps/web/tsconfig.app.json
+++ b/apps/web/tsconfig.app.json
@@ -10,7 +10,7 @@
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
- "verbatimModuleSyntax": true,
+ "verbatimModuleSyntax": false,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
@@ -23,6 +23,7 @@
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"],
+ "@workspace/blocks/*": ["../../packages/blocks/src/*"],
"@workspace/ui/*": ["../../packages/ui/src/*"]
}
},
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index ad260fc..5899638 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -7,6 +7,7 @@
"compilerOptions": {
"paths": {
"@/*": ["./src/*"],
+ "@workspace/blocks/*": ["../../packages/blocks/src/*"],
"@workspace/ui/*": ["../../packages/ui/src/*"]
}
}
diff --git a/apps/web/tsconfig.node.json b/apps/web/tsconfig.node.json
index d3c52ea..8ea6773 100644
--- a/apps/web/tsconfig.node.json
+++ b/apps/web/tsconfig.node.json
@@ -10,6 +10,7 @@
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
@@ -20,5 +21,5 @@
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
- "include": ["vite.config.ts"]
+ "include": ["vite.config.ts", "lingui.config.ts"]
}
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index 79a65e0..87d1fb4 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -1,14 +1,36 @@
import path from "path"
import tailwindcss from "@tailwindcss/vite"
+import { tanstackStart } from "@tanstack/react-start/plugin/vite"
+import { i18n } from "@workspace/i18n/vite"
import react from "@vitejs/plugin-react"
+import { nitro } from "nitro/vite"
import { defineConfig } from "vite"
// https://vite.dev/config/
export default defineConfig({
- plugins: [react(), tailwindcss()],
resolve: {
+ tsconfigPaths: true,
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
+ optimizeDeps: {
+ include: ["use-sync-external-store/shim/with-selector"],
+ },
+ plugins: [
+ i18n(),
+ tailwindcss(),
+ tanstackStart({
+ router: {
+ generatedRouteTree: "route-tree.gen.ts",
+ },
+ srcDirectory: "src",
+ }),
+ react(),
+ nitro(),
+ ],
+ server: {
+ host: "0.0.0.0",
+ port: 31573,
+ },
})