Products are nearly finished, also switched to mock api

This commit is contained in:
Thastertyn 2025-04-19 11:02:13 +02:00
parent 0986336aea
commit c60ec969d5
27 changed files with 1486 additions and 276 deletions

View File

@ -29,7 +29,7 @@
"@radix-ui/react-scroll-area": "^1.2.1", "@radix-ui/react-scroll-area": "^1.2.1",
"@radix-ui/react-select": "^2.1.2", "@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-toast": "^1.2.2",
@ -45,6 +45,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.0.4", "cmdk": "^1.0.4",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"dexie": "^4.0.11",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lucide-react": "^0.475.0", "lucide-react": "^0.475.0",
@ -56,6 +57,7 @@
"recharts": "^2.14.1", "recharts": "^2.14.1",
"tailwind-merge": "^3.0.1", "tailwind-merge": "^3.0.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"uuid": "^11.1.0",
"zod": "^3.24.2", "zod": "^3.24.2",
"zustand": "^5.0.3" "zustand": "^5.0.3"
}, },
@ -86,6 +88,7 @@
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "~5.7.2", "typescript": "~5.7.2",
"typescript-eslint": "^8.22.0", "typescript-eslint": "^8.22.0",
"vite": "^6.1.0" "vite": "^6.1.0",
"vite-plugin-svgr": "^4.3.0"
} }
} }

298
frontend/pnpm-lock.yaml generated
View File

@ -54,7 +54,7 @@ importers:
specifier: ^1.1.0 specifier: ^1.1.0
version: 1.1.3(@types/react-dom@19.1.2(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) version: 1.1.3(@types/react-dom@19.1.2(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-slot': '@radix-ui/react-slot':
specifier: ^1.1.2 specifier: ^1.2.0
version: 1.2.0(@types/react@19.1.0)(react@19.1.0) version: 1.2.0(@types/react@19.1.0)(react@19.1.0)
'@radix-ui/react-switch': '@radix-ui/react-switch':
specifier: ^1.1.1 specifier: ^1.1.1
@ -101,6 +101,9 @@ importers:
date-fns: date-fns:
specifier: ^3.6.0 specifier: ^3.6.0
version: 3.6.0 version: 3.6.0
dexie:
specifier: ^4.0.11
version: 4.0.11
js-cookie: js-cookie:
specifier: ^3.0.5 specifier: ^3.0.5
version: 3.0.5 version: 3.0.5
@ -134,6 +137,9 @@ importers:
tailwindcss-animate: tailwindcss-animate:
specifier: ^1.0.7 specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.17) version: 1.0.7(tailwindcss@3.4.17)
uuid:
specifier: ^11.1.0
version: 11.1.0
zod: zod:
specifier: ^3.24.2 specifier: ^3.24.2
version: 3.24.2 version: 3.24.2
@ -222,6 +228,9 @@ importers:
vite: vite:
specifier: ^6.1.0 specifier: ^6.1.0
version: 6.2.6(@types/node@22.14.0)(jiti@2.4.2)(tsx@4.19.3)(yaml@2.7.1) version: 6.2.6(@types/node@22.14.0)(jiti@2.4.2)(tsx@4.19.3)(yaml@2.7.1)
vite-plugin-svgr:
specifier: ^4.3.0
version: 4.3.0(rollup@4.39.0)(typescript@5.7.3)(vite@6.2.6(@types/node@22.14.0)(jiti@2.4.2)(tsx@4.19.3)(yaml@2.7.1))
packages: packages:
@ -1147,6 +1156,15 @@ packages:
'@radix-ui/rect@1.1.1': '@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
'@rollup/pluginutils@5.1.4':
resolution: {integrity: sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==}
engines: {node: '>=14.0.0'}
peerDependencies:
rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
peerDependenciesMeta:
rollup:
optional: true
'@rollup/rollup-android-arm-eabi@4.39.0': '@rollup/rollup-android-arm-eabi@4.39.0':
resolution: {integrity: sha512-lGVys55Qb00Wvh8DMAocp5kIcaNzEFTmGhfFd88LfaogYTRKrdxgtlO5H6S49v2Nd8R2C6wLOal0qv6/kCkOwA==} resolution: {integrity: sha512-lGVys55Qb00Wvh8DMAocp5kIcaNzEFTmGhfFd88LfaogYTRKrdxgtlO5H6S49v2Nd8R2C6wLOal0qv6/kCkOwA==}
cpu: [arm] cpu: [arm]
@ -1247,6 +1265,74 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@svgr/babel-plugin-add-jsx-attribute@8.0.0':
resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==}
engines: {node: '>=14'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@svgr/babel-plugin-remove-jsx-attribute@8.0.0':
resolution: {integrity: sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==}
engines: {node: '>=14'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0':
resolution: {integrity: sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==}
engines: {node: '>=14'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0':
resolution: {integrity: sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==}
engines: {node: '>=14'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@svgr/babel-plugin-svg-dynamic-title@8.0.0':
resolution: {integrity: sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==}
engines: {node: '>=14'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@svgr/babel-plugin-svg-em-dimensions@8.0.0':
resolution: {integrity: sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==}
engines: {node: '>=14'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@svgr/babel-plugin-transform-react-native-svg@8.1.0':
resolution: {integrity: sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==}
engines: {node: '>=14'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@svgr/babel-plugin-transform-svg-component@8.0.0':
resolution: {integrity: sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==}
engines: {node: '>=12'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@svgr/babel-preset@8.1.0':
resolution: {integrity: sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==}
engines: {node: '>=14'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@svgr/core@8.1.0':
resolution: {integrity: sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==}
engines: {node: '>=14'}
'@svgr/hast-util-to-babel-ast@8.0.0':
resolution: {integrity: sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==}
engines: {node: '>=14'}
'@svgr/plugin-jsx@8.1.0':
resolution: {integrity: sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==}
engines: {node: '>=14'}
peerDependencies:
'@svgr/core': '*'
'@swc/core-darwin-arm64@1.11.18': '@swc/core-darwin-arm64@1.11.18':
resolution: {integrity: sha512-K6AntdUlNMQg8aChqjeXwnVhK6d4WRZ9TgtLSTmdU0Ugll4an7QK49s9NrT7XQU91cEsVvzdr++p1bNImx0hJg==} resolution: {integrity: sha512-K6AntdUlNMQg8aChqjeXwnVhK6d4WRZ9TgtLSTmdU0Ugll4an7QK49s9NrT7XQU91cEsVvzdr++p1bNImx0hJg==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -1691,6 +1777,10 @@ packages:
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
camelcase@6.3.0:
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
engines: {node: '>=10'}
caniuse-lite@1.0.30001713: caniuse-lite@1.0.30001713:
resolution: {integrity: sha512-wCIWIg+A4Xr7NfhTuHdX+/FKh3+Op3LBbSp2N5Pfx6T/LhdQy3GTyoTg48BReaW/MyMNZAkTadsBtai3ldWK0Q==} resolution: {integrity: sha512-wCIWIg+A4Xr7NfhTuHdX+/FKh3+Op3LBbSp2N5Pfx6T/LhdQy3GTyoTg48BReaW/MyMNZAkTadsBtai3ldWK0Q==}
@ -1765,6 +1855,15 @@ packages:
convert-source-map@2.0.0: convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
cosmiconfig@8.3.6:
resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==}
engines: {node: '>=14'}
peerDependencies:
typescript: '>=4.9.5'
peerDependenciesMeta:
typescript:
optional: true
country-flag-icons@1.5.18: country-flag-icons@1.5.18:
resolution: {integrity: sha512-z+Uzesi8u8IdkViqqbzzbkf3+a7WJpcET5B7sPwTg7GXqPYpVEgNlZ/FC3l8KO4mEf+mNkmzKLppKTN4PlCJEQ==} resolution: {integrity: sha512-z+Uzesi8u8IdkViqqbzzbkf3+a7WJpcET5B7sPwTg7GXqPYpVEgNlZ/FC3l8KO4mEf+mNkmzKLppKTN4PlCJEQ==}
@ -1864,6 +1963,9 @@ packages:
detect-node-es@1.1.0: detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
dexie@4.0.11:
resolution: {integrity: sha512-SOKO002EqlvBYYKQSew3iymBoN2EQ4BDw/3yprjh7kAfFzjBYkaMNa/pZvcA7HSWlcKSQb9XhPe3wKyQ0x4A8A==}
didyoumean@1.2.2: didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
@ -1877,6 +1979,9 @@ packages:
dom-helpers@5.2.1: dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
dot-case@3.0.4:
resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==}
dotenv@16.4.7: dotenv@16.4.7:
resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==} resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==}
engines: {node: '>=12'} engines: {node: '>=12'}
@ -1904,6 +2009,13 @@ packages:
resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==} resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
error-ex@1.3.2:
resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==}
es-define-property@1.0.1: es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -1982,6 +2094,9 @@ packages:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'} engines: {node: '>=4.0'}
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
esutils@2.0.3: esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -2170,6 +2285,9 @@ packages:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'} engines: {node: '>=12'}
is-arrayish@0.2.1:
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
is-binary-path@2.1.0: is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -2235,6 +2353,9 @@ packages:
json-buffer@3.0.1: json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
json-parse-even-better-errors@2.3.1:
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
json-schema-traverse@0.4.1: json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
@ -2289,6 +2410,9 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true hasBin: true
lower-case@2.0.2:
resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==}
lru-cache@10.4.3: lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
@ -2371,6 +2495,9 @@ packages:
neo-async@2.6.2: neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
no-case@3.0.4:
resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==}
node-fetch-native@1.6.6: node-fetch-native@1.6.6:
resolution: {integrity: sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ==} resolution: {integrity: sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ==}
@ -2420,6 +2547,10 @@ packages:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'} engines: {node: '>=6'}
parse-json@5.2.0:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
parse-ms@4.0.0: parse-ms@4.0.0:
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -2439,6 +2570,10 @@ packages:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'} engines: {node: '>=16 || 14 >=14.18'}
path-type@4.0.0:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'}
pathe@1.1.2: pathe@1.1.2:
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
@ -2760,6 +2895,9 @@ packages:
resolution: {integrity: sha512-tEYNll18pPKHroYSmLLrksq233j021G0giwW7P3D24jC54pQ5W5BXMsQ/Mvw1OJCmEYDgY+lrzT+3nNUtoNfXQ==} resolution: {integrity: sha512-tEYNll18pPKHroYSmLLrksq233j021G0giwW7P3D24jC54pQ5W5BXMsQ/Mvw1OJCmEYDgY+lrzT+3nNUtoNfXQ==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
snake-case@3.0.4:
resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==}
solid-js@1.9.5: solid-js@1.9.5:
resolution: {integrity: sha512-ogI3DaFcyn6UhYhrgcyRAMbu/buBJitYQASZz5WzfQVPP10RD2AbCoRZ517psnezrasyCbWzIxZ6kVqet768xw==} resolution: {integrity: sha512-ogI3DaFcyn6UhYhrgcyRAMbu/buBJitYQASZz5WzfQVPP10RD2AbCoRZ517psnezrasyCbWzIxZ6kVqet768xw==}
@ -2812,6 +2950,9 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
svg-parser@2.0.4:
resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==}
tailwind-merge@3.2.0: tailwind-merge@3.2.0:
resolution: {integrity: sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA==} resolution: {integrity: sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA==}
@ -2942,9 +3083,18 @@ packages:
util-deprecate@1.0.2: util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
uuid@11.1.0:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
victory-vendor@36.9.2: victory-vendor@36.9.2:
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
vite-plugin-svgr@4.3.0:
resolution: {integrity: sha512-Jy9qLB2/PyWklpYy0xk0UU3TlU0t2UMpJXZvf+hWII1lAmRHrOUKi11Uw8N3rxoNk7atZNYO3pR3vI1f7oi+6w==}
peerDependencies:
vite: '>=2.6.0'
vite@6.2.6: vite@6.2.6:
resolution: {integrity: sha512-9xpjNl3kR4rVDZgPNdTL0/c6ao4km69a/2ihNQbcANz8RuCOK3hQBmLSJf3bRKVQjVMda+YvizNE8AwvogcPbw==} resolution: {integrity: sha512-9xpjNl3kR4rVDZgPNdTL0/c6ao4km69a/2ihNQbcANz8RuCOK3hQBmLSJf3bRKVQjVMda+YvizNE8AwvogcPbw==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@ -3979,6 +4129,14 @@ snapshots:
'@radix-ui/rect@1.1.1': {} '@radix-ui/rect@1.1.1': {}
'@rollup/pluginutils@5.1.4(rollup@4.39.0)':
dependencies:
'@types/estree': 1.0.7
estree-walker: 2.0.2
picomatch: 4.0.2
optionalDependencies:
rollup: 4.39.0
'@rollup/rollup-android-arm-eabi@4.39.0': '@rollup/rollup-android-arm-eabi@4.39.0':
optional: true optional: true
@ -4039,6 +4197,76 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.39.0': '@rollup/rollup-win32-x64-msvc@4.39.0':
optional: true optional: true
'@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.26.10)':
dependencies:
'@babel/core': 7.26.10
'@svgr/babel-plugin-remove-jsx-attribute@8.0.0(@babel/core@7.26.10)':
dependencies:
'@babel/core': 7.26.10
'@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0(@babel/core@7.26.10)':
dependencies:
'@babel/core': 7.26.10
'@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0(@babel/core@7.26.10)':
dependencies:
'@babel/core': 7.26.10
'@svgr/babel-plugin-svg-dynamic-title@8.0.0(@babel/core@7.26.10)':
dependencies:
'@babel/core': 7.26.10
'@svgr/babel-plugin-svg-em-dimensions@8.0.0(@babel/core@7.26.10)':
dependencies:
'@babel/core': 7.26.10
'@svgr/babel-plugin-transform-react-native-svg@8.1.0(@babel/core@7.26.10)':
dependencies:
'@babel/core': 7.26.10
'@svgr/babel-plugin-transform-svg-component@8.0.0(@babel/core@7.26.10)':
dependencies:
'@babel/core': 7.26.10
'@svgr/babel-preset@8.1.0(@babel/core@7.26.10)':
dependencies:
'@babel/core': 7.26.10
'@svgr/babel-plugin-add-jsx-attribute': 8.0.0(@babel/core@7.26.10)
'@svgr/babel-plugin-remove-jsx-attribute': 8.0.0(@babel/core@7.26.10)
'@svgr/babel-plugin-remove-jsx-empty-expression': 8.0.0(@babel/core@7.26.10)
'@svgr/babel-plugin-replace-jsx-attribute-value': 8.0.0(@babel/core@7.26.10)
'@svgr/babel-plugin-svg-dynamic-title': 8.0.0(@babel/core@7.26.10)
'@svgr/babel-plugin-svg-em-dimensions': 8.0.0(@babel/core@7.26.10)
'@svgr/babel-plugin-transform-react-native-svg': 8.1.0(@babel/core@7.26.10)
'@svgr/babel-plugin-transform-svg-component': 8.0.0(@babel/core@7.26.10)
'@svgr/core@8.1.0(typescript@5.7.3)':
dependencies:
'@babel/core': 7.26.10
'@svgr/babel-preset': 8.1.0(@babel/core@7.26.10)
camelcase: 6.3.0
cosmiconfig: 8.3.6(typescript@5.7.3)
snake-case: 3.0.4
transitivePeerDependencies:
- supports-color
- typescript
'@svgr/hast-util-to-babel-ast@8.0.0':
dependencies:
'@babel/types': 7.27.0
entities: 4.5.0
'@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@5.7.3))':
dependencies:
'@babel/core': 7.26.10
'@svgr/babel-preset': 8.1.0(@babel/core@7.26.10)
'@svgr/core': 8.1.0(typescript@5.7.3)
'@svgr/hast-util-to-babel-ast': 8.0.0
svg-parser: 2.0.4
transitivePeerDependencies:
- supports-color
'@swc/core-darwin-arm64@1.11.18': '@swc/core-darwin-arm64@1.11.18':
optional: true optional: true
@ -4523,6 +4751,8 @@ snapshots:
camelcase-css@2.0.1: {} camelcase-css@2.0.1: {}
camelcase@6.3.0: {}
caniuse-lite@1.0.30001713: {} caniuse-lite@1.0.30001713: {}
chalk@4.1.2: chalk@4.1.2:
@ -4597,6 +4827,15 @@ snapshots:
convert-source-map@2.0.0: {} convert-source-map@2.0.0: {}
cosmiconfig@8.3.6(typescript@5.7.3):
dependencies:
import-fresh: 3.3.1
js-yaml: 4.1.0
parse-json: 5.2.0
path-type: 4.0.0
optionalDependencies:
typescript: 5.7.3
country-flag-icons@1.5.18: {} country-flag-icons@1.5.18: {}
cross-spawn@7.0.6: cross-spawn@7.0.6:
@ -4674,6 +4913,8 @@ snapshots:
detect-node-es@1.1.0: {} detect-node-es@1.1.0: {}
dexie@4.0.11: {}
didyoumean@1.2.2: {} didyoumean@1.2.2: {}
diff@7.0.0: {} diff@7.0.0: {}
@ -4685,6 +4926,11 @@ snapshots:
'@babel/runtime': 7.27.0 '@babel/runtime': 7.27.0
csstype: 3.1.3 csstype: 3.1.3
dot-case@3.0.4:
dependencies:
no-case: 3.0.4
tslib: 2.8.1
dotenv@16.4.7: {} dotenv@16.4.7: {}
dunder-proto@1.0.1: dunder-proto@1.0.1:
@ -4712,6 +4958,12 @@ snapshots:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
tapable: 2.2.1 tapable: 2.2.1
entities@4.5.0: {}
error-ex@1.3.2:
dependencies:
is-arrayish: 0.2.1
es-define-property@1.0.1: {} es-define-property@1.0.1: {}
es-errors@1.3.0: {} es-errors@1.3.0: {}
@ -4834,6 +5086,8 @@ snapshots:
estraverse@5.3.0: {} estraverse@5.3.0: {}
estree-walker@2.0.2: {}
esutils@2.0.3: {} esutils@2.0.3: {}
eventemitter3@4.0.7: {} eventemitter3@4.0.7: {}
@ -5011,6 +5265,8 @@ snapshots:
internmap@2.0.3: {} internmap@2.0.3: {}
is-arrayish@0.2.1: {}
is-binary-path@2.1.0: is-binary-path@2.1.0:
dependencies: dependencies:
binary-extensions: 2.3.0 binary-extensions: 2.3.0
@ -5057,6 +5313,8 @@ snapshots:
json-buffer@3.0.1: {} json-buffer@3.0.1: {}
json-parse-even-better-errors@2.3.1: {}
json-schema-traverse@0.4.1: {} json-schema-traverse@0.4.1: {}
json-stable-stringify-without-jsonify@1.0.1: {} json-stable-stringify-without-jsonify@1.0.1: {}
@ -5111,6 +5369,10 @@ snapshots:
dependencies: dependencies:
js-tokens: 4.0.0 js-tokens: 4.0.0
lower-case@2.0.2:
dependencies:
tslib: 2.8.1
lru-cache@10.4.3: {} lru-cache@10.4.3: {}
lru-cache@5.1.1: lru-cache@5.1.1:
@ -5182,6 +5444,11 @@ snapshots:
neo-async@2.6.2: {} neo-async@2.6.2: {}
no-case@3.0.4:
dependencies:
lower-case: 2.0.2
tslib: 2.8.1
node-fetch-native@1.6.6: {} node-fetch-native@1.6.6: {}
node-releases@2.0.19: {} node-releases@2.0.19: {}
@ -5228,6 +5495,13 @@ snapshots:
dependencies: dependencies:
callsites: 3.1.0 callsites: 3.1.0
parse-json@5.2.0:
dependencies:
'@babel/code-frame': 7.26.2
error-ex: 1.3.2
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
parse-ms@4.0.0: {} parse-ms@4.0.0: {}
path-exists@4.0.0: {} path-exists@4.0.0: {}
@ -5241,6 +5515,8 @@ snapshots:
lru-cache: 10.4.3 lru-cache: 10.4.3
minipass: 7.1.2 minipass: 7.1.2
path-type@4.0.0: {}
pathe@1.1.2: {} pathe@1.1.2: {}
pathe@2.0.3: {} pathe@2.0.3: {}
@ -5502,6 +5778,11 @@ snapshots:
smol-toml@1.3.1: {} smol-toml@1.3.1: {}
snake-case@3.0.4:
dependencies:
dot-case: 3.0.4
tslib: 2.8.1
solid-js@1.9.5: solid-js@1.9.5:
dependencies: dependencies:
csstype: 3.1.3 csstype: 3.1.3
@ -5554,6 +5835,8 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {} supports-preserve-symlinks-flag@1.0.0: {}
svg-parser@2.0.4: {}
tailwind-merge@3.2.0: {} tailwind-merge@3.2.0: {}
tailwindcss-animate@1.0.7(tailwindcss@3.4.17): tailwindcss-animate@1.0.7(tailwindcss@3.4.17):
@ -5693,6 +5976,8 @@ snapshots:
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
uuid@11.1.0: {}
victory-vendor@36.9.2: victory-vendor@36.9.2:
dependencies: dependencies:
'@types/d3-array': 3.2.1 '@types/d3-array': 3.2.1
@ -5710,6 +5995,17 @@ snapshots:
d3-time: 3.1.0 d3-time: 3.1.0
d3-timer: 3.0.1 d3-timer: 3.0.1
vite-plugin-svgr@4.3.0(rollup@4.39.0)(typescript@5.7.3)(vite@6.2.6(@types/node@22.14.0)(jiti@2.4.2)(tsx@4.19.3)(yaml@2.7.1)):
dependencies:
'@rollup/pluginutils': 5.1.4(rollup@4.39.0)
'@svgr/core': 8.1.0(typescript@5.7.3)
'@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.7.3))
vite: 6.2.6(@types/node@22.14.0)(jiti@2.4.2)(tsx@4.19.3)(yaml@2.7.1)
transitivePeerDependencies:
- rollup
- supports-color
- typescript
vite@6.2.6(@types/node@22.14.0)(jiti@2.4.2)(tsx@4.19.3)(yaml@2.7.1): vite@6.2.6(@types/node@22.14.0)(jiti@2.4.2)(tsx@4.19.3)(yaml@2.7.1):
dependencies: dependencies:
esbuild: 0.25.2 esbuild: 0.25.2

View File

@ -0,0 +1,19 @@
import {
UserPublic,
UserRegister,
UserUpdate,
ShopLoginAccessTokenData
} from "@/client";
import { Shop } from "./mock/models";
export interface AuthAPI {
getCurrentUser(): Promise<UserPublic | null>;
registerUser(data: UserRegister): Promise<void>;
loginUser(data: ShopLoginAccessTokenData): Promise<{ access_token: string }>;
updateUser(data: UserUpdate): Promise<void>;
}
export interface ShopAPI {
getShop(): Promise<Shop | null>;
updateShop(data: Partial<Shop>): Promise<void>;
}

12
frontend/src/api/api.ts Normal file
View File

@ -0,0 +1,12 @@
import { RealAuthAPI } from "./real/auth-real-api";
import { MockAuthAPI } from "./mock/auth-mock-api";
import { AuthAPI, ShopAPI } from "./api-definition";
import { MockShopAPI } from "./mock/shop-mock-api";
import { MockProductAPI } from "./mock/products-mock-api";
export const authAPI: AuthAPI =
import.meta.env.VITE_USE_MOCK_API === "true" ? MockAuthAPI : RealAuthAPI;
export const shopAPI: ShopAPI = MockShopAPI;
export const productsAPI = MockProductAPI;

View File

@ -0,0 +1,121 @@
import { AuthAPI } from "../api-definition";
import { v4 as uuidv4 } from "uuid";
import { UserRegister, UserUpdate, ShopLoginAccessTokenData } from "@/client";
import { extractSubFromJWT, generateFakeJWT } from "@/utils/jwt";
import { mockDB } from "./db";
import { MockUser, Shop } from "./models";
const db = mockDB;
export const MockAuthAPI: AuthAPI = {
async getCurrentUser() {
const token = localStorage.getItem("access_token");
if (!token) return null;
const userUUID = extractSubFromJWT(token);
if (!userUUID) return null;
const user = await db.users.where("uuid").equals(userUUID).first();
if (!user) return null;
const publicUser = {
...user,
first_name: user.first_name ?? null,
last_name: user.last_name ?? null,
profile_picture: user.profile_picture ?? null
};
return publicUser;
},
async registerUser(data: UserRegister) {
const now = new Date().toISOString();
const userId = Date.now();
const userUUID = uuidv4();
const shopUUID = uuidv4();
const newUser: MockUser = {
id: userId,
uuid: userUUID,
user_role: "customer",
status: "customer",
shop_id: null,
username: data.username,
email: data.email,
password: data.password,
first_name: undefined,
last_name: undefined,
phone_number: data.phone_number,
profile_picture: undefined,
created_at: now,
updated_at: now,
last_login: now
};
const newShop: Shop = {
id: userId,
uuid: shopUUID,
owner_id: userId,
name: `${data.username}'s Shop`,
description: "A newly created shop.",
created_at: now,
updated_at: now,
status: "inactive",
logo: null,
contact_email: data.email,
contact_phone_number: data.phone_number,
currency: "USD",
address: {
street: "",
city: "",
state: null,
postal_code: "",
country: ""
}
};
await mockDB.transaction('rw', mockDB.users, mockDB.shops, mockDB.preferences, mockDB.statistics, async () => {
await mockDB.users.add(newUser);
await mockDB.preferences.add({ user_id: userId });
await mockDB.statistics.add({ user_id: userId, total_spend: 0 });
await mockDB.shops.add(newShop);
});
},
async loginUser(data: ShopLoginAccessTokenData) {
const user = await db.users
.where("email")
.equals(data.formData.username)
.first();
if (!user || user.password != data.formData.password) {
throw new Error("Invalid credentials");
}
await db.users.update(user.id, { last_login: new Date().toISOString() });
const token = generateFakeJWT(user.uuid);
localStorage.setItem("access_token", token);
return { access_token: token };
},
async updateUser(data: UserUpdate) {
const token = localStorage.getItem("access_token");
if (!token) return;
const userUUID = extractSubFromJWT(token);
if (!userUUID) return;
await db.users
.where("uuid")
.equals(userUUID)
.modify({
updated_at: new Date().toISOString(),
email: data.email ?? "",
phone_number: data.phone_number ?? "",
username: data.username ?? "",
first_name: data.first_name ?? undefined,
last_name: data.last_name ?? undefined
});
}
};

View File

@ -0,0 +1,41 @@
import Dexie, { Table } from "dexie";
import {
MockUser,
MockUserPreferences,
MockUserStatistics,
Product,
ProductCategory,
ProductCategoryJunction,
ProductImage,
ProductVariant,
Shop
} from "./models";
class MockDB extends Dexie {
users!: Table<MockUser, number>;
preferences!: Table<MockUserPreferences, number>;
statistics!: Table<MockUserStatistics, number>;
shops!: Table<Shop, number>;
products!: Table<Product, number>;
product_variants!: Table<ProductVariant, number>;
product_images!: Table<ProductImage, number>;
product_categories!: Table<ProductCategory, number>;
product_category_junctions!: Table<ProductCategoryJunction, [number, number]>;
constructor() {
super("MockDB");
this.version(1).stores({
users: "++id,username,email,uuid",
preferences: "user_id",
statistics: "user_id",
shops: "++id,uuid,name",
products: "++id,shop_id,name",
product_variants: "++id,product_id",
product_images: "++id,product_id",
product_categories: "++id,parent_category_id",
product_category_junctions: "[product_id+category_id]"
});
}
}
export const mockDB = new MockDB();

View File

@ -0,0 +1,107 @@
import { UserRegister } from "@/client";
export type UserRole = "owner" | "customer" | "employee" | "manager" | "admin";
export interface MockUser extends Omit<UserRegister, "password"> {
id: number;
uuid: string;
user_role: UserRole;
shop_id: number | null;
status: UserRole;
password: string;
created_at: string;
updated_at: string;
last_login?: string;
first_name?: string;
last_name?: string;
profile_picture?: string;
}
export interface MockUserPreferences {
user_id: number;
}
export interface MockUserStatistics {
user_id: number;
total_spend?: number;
}
export type ShopStatus = "active" | "inactive" | "suspended";
export interface ShopAddress {
street: string;
city: string;
state?: string | null;
postal_code: string;
country: string;
}
export interface Shop {
id: number;
uuid: string;
owner_id: number;
name: string;
description: string;
created_at: string;
updated_at: string;
status: ShopStatus;
logo?: string | null;
contact_email: string;
contact_phone_number: string;
address: ShopAddress;
currency: string;
}
export interface Product {
id: number;
shop_id: number;
name: string;
description: string;
price: number;
stock_quantity: number;
created_at: string;
updated_at: string;
}
export interface ProductCategoryJunction {
product_id: number;
category_id: number;
}
export interface ProductCategory {
id: number;
parent_category_id?: number;
name: string;
}
export interface ProductImage {
id: number;
product_id: number;
image_id: number;
image_url: string;
alt_text: string;
}
export interface ProductVariant {
id: number;
product_id: number;
index: number; // used for ordering
name: string;
price: number;
comment?: string;
created_at: string;
updated_at: string;
}
export interface ProductCreate {
name: string;
description: string;
price: number;
stock_quantity: number;
image_data?: string | undefined;
}
export interface ProductWithDetails extends Product {
images: ProductImage[];
variants: ProductVariant[];
categories: ProductCategory[];
}

View File

@ -0,0 +1,163 @@
import { mockDB } from "./db";
import { ProductCategory, ProductCreate, ProductWithDetails } from "./models";
import { getCurrentUserDirect } from "./utils/currentUser";
export const MockProductAPI = {
async getProductsForShop(): Promise<ProductWithDetails[]> {
const user = await getCurrentUserDirect();
if (!user?.id) return [];
const products = await mockDB.products
.where("shop_id")
.equals(user.id)
.toArray();
const detailedProducts = await Promise.all(
products.map(async (product) => {
const images = await mockDB.product_images
.where("product_id")
.equals(product.id)
.toArray();
const variants = await mockDB.product_variants
.where("product_id")
.equals(product.id)
.toArray();
const categoryLinks = await mockDB.product_category_junctions
.where("product_id")
.equals(product.id)
.toArray();
const rawCategories = await Promise.all(
categoryLinks.map((link) =>
mockDB.product_categories.get(link.category_id)
)
);
const categories = rawCategories.filter(
(c): c is ProductCategory => c !== undefined
);
return {
...product,
images,
variants,
categories
};
})
);
return detailedProducts;
},
async getProductById(productId: number): Promise<ProductWithDetails | null> {
const product = await mockDB.products.get(productId);
if (!product) return null;
const images = await mockDB.product_images
.where("product_id")
.equals(productId)
.toArray();
const variants = await mockDB.product_variants
.where("product_id")
.equals(productId)
.toArray();
const categoryLinks = await mockDB.product_category_junctions
.where("product_id")
.equals(productId)
.toArray();
const rawCategories = await Promise.all(
categoryLinks.map((link) =>
mockDB.product_categories.get(link.category_id)
)
);
const categories = rawCategories.filter(
(c): c is ProductCategory => c !== undefined
);
return {
...product,
images,
variants,
categories
};
},
async createProduct(productData: ProductCreate) {
const user = await getCurrentUserDirect();
if (!user?.id) return null;
const now = new Date().toISOString();
const productId = Date.now();
await mockDB.products.add({
id: productId,
...productData,
shop_id: user.id,
created_at: now,
updated_at: now
});
if ("image_data" in productData && productData.image_data) {
const image_id = Date.now();
await mockDB.product_images.add({
id: image_id,
product_id: productId,
image_id: image_id,
image_url: productData.image_data,
alt_text: `${productData.name} image`
});
}
return mockDB.products.get(productId);
},
async updateProduct(productId: number, data: Partial<ProductCreate>) {
const product = await mockDB.products.get(productId);
if (!product) return null;
const updatedProduct = {
...product,
...data,
updated_at: new Date().toISOString()
};
await mockDB.products.put(updatedProduct);
if (data.image_data) {
const existingImage = await mockDB.product_images
.where("product_id")
.equals(productId)
.first();
if (existingImage) {
await mockDB.product_images.put({
...existingImage,
image_url: data.image_data,
alt_text: `${data.name ?? product.name} image`
});
} else {
const image_id = Date.now();
await mockDB.product_images.add({
id: image_id,
product_id: productId,
image_id: image_id,
image_url: data.image_data,
alt_text: `${data.name ?? product.name} image`
});
}
}
return updatedProduct;
},
async deleteProduct(productId: number) {
await mockDB.product_images.where("product_id").equals(productId).delete();
await mockDB.product_variants
.where("product_id")
.equals(productId)
.delete();
await mockDB.product_category_junctions
.where("product_id")
.equals(productId)
.delete();
await mockDB.products.delete(productId);
return true;
}
};

View File

@ -0,0 +1,41 @@
import { ShopAPI } from "../api-definition";
import { mockDB } from "./db";
import { extractSubFromJWT } from "@/utils/jwt";
const db = mockDB;
export const MockShopAPI: ShopAPI = {
async getShop() {
const token = localStorage.getItem("access_token");
if (!token) return null;
const uuid = extractSubFromJWT(token);
if (!uuid) return null;
const user = await db.users.where("uuid").equals(uuid).first();
if (!user?.id) return null;
const dbShop = await db.shops.get(user.id);
return dbShop ?? null;
},
async updateShop(data) {
const token = localStorage.getItem("access_token");
console.log("Token ->", token);
if (!token) return;
const uuid = extractSubFromJWT(token);
console.log("UUID ->", uuid);
if (!uuid) return;
const user = await db.users.where("uuid").equals(uuid).first();
console.log("User ->", user);
if (!user?.id) return;
console.log("Saving data ->", data);
await db.shops.update(user.id, {
...data,
updated_at: new Date().toISOString()
});
}
};

View File

@ -0,0 +1,13 @@
import { mockDB } from "../db";
import { extractSubFromJWT } from "@/utils/jwt";
export async function getCurrentUserDirect() {
const token = localStorage.getItem("access_token");
if (!token) return null;
const uuid = extractSubFromJWT(token);
if (!uuid) return null;
const user = await mockDB.users.where("uuid").equals(uuid).first();
return user ?? null;
}

View File

@ -0,0 +1,7 @@
export const fileToBase64 = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});

View File

@ -0,0 +1,19 @@
import { DashboardService, LoginService, UserService } from "@/client";
import { AuthAPI } from "../api-definition";
export const RealAuthAPI: AuthAPI = {
async getCurrentUser() {
return DashboardService.userGetUser();
},
async registerUser(data) {
await DashboardService.userRegister({ requestBody: data });
},
async loginUser(data) {
return LoginService.dashboardLoginAccessToken({
formData: data.formData
});
},
async updateUser(data) {
await UserService.userUpdateUser({ requestBody: data });
}
};

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg
viewBox="0 0 120 120"
xmlns="http://www.w3.org/2000/svg">
<rect width="120" height="120" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M33.2503 38.4816C33.2603 37.0472 34.4199 35.8864 35.8543 35.875H83.1463C84.5848 35.875 85.7503 37.0431 85.7503 38.4816V80.5184C85.7403 81.9528 84.5807 83.1136 83.1463 83.125H35.8543C34.4158 83.1236 33.2503 81.957 33.2503 80.5184V38.4816ZM80.5006 41.1251H38.5006V77.8751L62.8921 53.4783C63.9172 52.4536 65.5788 52.4536 66.6039 53.4783L80.5006 67.4013V41.1251ZM43.75 51.6249C43.75 54.5244 46.1005 56.8749 49 56.8749C51.8995 56.8749 54.25 54.5244 54.25 51.6249C54.25 48.7254 51.8995 46.3749 49 46.3749C46.1005 46.3749 43.75 48.7254 43.75 51.6249Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 763 B

View File

@ -4,17 +4,11 @@ import { useEffect, useState } from "react";
import { handleServerError } from "@/utils/handle-server-error"; import { handleServerError } from "@/utils/handle-server-error";
import { ApiError } from "@/errors/api-error"; import { ApiError } from "@/errors/api-error";
import { import { ShopLoginAccessTokenData, UserPublic } from "@/client";
DashboardService,
LoginService,
ShopLoginAccessTokenData,
UserPublic,
UserRegister,
UserService,
UserUpdate
} from "@/client";
import { toast } from "./useToast"; import { toast } from "./useToast";
import { authAPI } from "@/api/api";
const isLoggedIn = () => { const isLoggedIn = () => {
return localStorage.getItem("access_token") !== null; return localStorage.getItem("access_token") !== null;
}; };
@ -27,25 +21,19 @@ const useAuth = () => {
const { data: user } = useQuery<UserPublic | null, Error>({ const { data: user } = useQuery<UserPublic | null, Error>({
queryKey: ["currentUser"], queryKey: ["currentUser"],
queryFn: DashboardService.userGetUser, queryFn: authAPI.getCurrentUser,
enabled: loggedIn enabled: loggedIn
}); });
const signUpMutation = useMutation({ const signUpMutation = useMutation({
mutationFn: (data: UserRegister) => mutationFn: authAPI.registerUser,
DashboardService.userRegister({ requestBody: data }),
onSuccess: () => navigate({ to: "/sign-in" }), onSuccess: () => navigate({ to: "/sign-in" }),
onError: (err: ApiError) => handleServerError(err), onError: (err: ApiError) => handleServerError(err),
onSettled: () => queryClient.invalidateQueries({ queryKey: ["users"] }) onSettled: () => queryClient.invalidateQueries({ queryKey: ["users"] })
}); });
const login = async (data: ShopLoginAccessTokenData) => { const login = async (data: ShopLoginAccessTokenData) => {
const response = await LoginService.dashboardLoginAccessToken({ const response = await authAPI.loginUser(data);
formData: {
username: data.formData.username,
password: data.formData.password
}
});
localStorage.setItem("access_token", response.access_token); localStorage.setItem("access_token", response.access_token);
setLoggedIn(true); setLoggedIn(true);
await queryClient.invalidateQueries({ queryKey: ["currentUser"] }); await queryClient.invalidateQueries({ queryKey: ["currentUser"] });
@ -65,8 +53,7 @@ const useAuth = () => {
}; };
const updateAccountMutation = useMutation({ const updateAccountMutation = useMutation({
mutationFn: (data: UserUpdate) => mutationFn: authAPI.updateUser,
UserService.userUpdateUser({ requestBody: data }),
onSuccess: () => { onSuccess: () => {
toast({ title: "Account updated successfully" }); toast({ title: "Account updated successfully" });
queryClient.invalidateQueries({ queryKey: ["currentUser"] }); queryClient.invalidateQueries({ queryKey: ["currentUser"] });
@ -77,7 +64,8 @@ const useAuth = () => {
useEffect(() => { useEffect(() => {
console.log("Checking whether the token is valid"); console.log("Checking whether the token is valid");
const userLoggedInAndNull = loggedIn && user === null; const userLoggedInAndNull = loggedIn && user === null;
const tokenExistsAndNull = Boolean(localStorage.getItem("access_token")) && user === null; const tokenExistsAndNull =
Boolean(localStorage.getItem("access_token")) && user === null;
if (userLoggedInAndNull || tokenExistsAndNull) { if (userLoggedInAndNull || tokenExistsAndNull) {
console.warn("User data is null while logged in, logging out."); console.warn("User data is null while logged in, logging out.");

View File

@ -0,0 +1,30 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ProductCreate, ProductWithDetails } from "@/api/mock/models";
import { productsAPI } from "@/api/api";
export function useProduct(productId?: number) {
const queryClient = useQueryClient();
const product = useQuery<ProductWithDetails | null>({
queryKey: ["product", productId],
queryFn: () => productsAPI.getProductById(productId!),
enabled: !!productId
});
const createProduct = useMutation({
mutationFn: (data: ProductCreate) => productsAPI.createProduct(data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["products"] })
});
const updateProduct = useMutation({
mutationFn: (data: Partial<ProductCreate>) => productsAPI.updateProduct(productId!, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["products"] })
});
const deleteProduct = useMutation({
mutationFn: () => productsAPI.deleteProduct(productId!),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["products"] })
});
return { product, createProduct, updateProduct, deleteProduct };
}

View File

@ -0,0 +1,13 @@
// src/hooks/useProducts.ts
import { useQuery } from "@tanstack/react-query";
import { productsAPI } from "@/api/api";
import { ProductWithDetails } from "@/api/mock/models";
export function useProducts() {
const query = useQuery<ProductWithDetails[]>({
queryKey: ["products"],
queryFn: productsAPI.getProductsForShop
});
return query;
}

View File

@ -0,0 +1,29 @@
import { shopAPI } from "@/api/api";
import { Shop } from "@/api/mock/models";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
export function useShop() {
const queryClient = useQueryClient();
const {
data: shop,
isLoading,
isError
} = useQuery<Shop | null>({
queryKey: ["shop"],
queryFn: shopAPI.getShop
});
const updateShopMutation = useMutation({
mutationFn: shopAPI.updateShop,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["shop"] })
});
return {
shop,
isLoading,
isError,
updateShop: updateShopMutation.mutate,
updateStatus: updateShopMutation.status
};
}

View File

@ -0,0 +1,152 @@
import React from "react";
import Placeholder from "@/assets/placeholder.svg?react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import { UseFormReturn } from "react-hook-form";
import { ProductForm } from "../hooks/use-product-form";
interface ProductDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
form: UseFormReturn<ProductForm>;
onSubmit: (e?: React.BaseSyntheticEvent) => void;
onDelete?: () => void;
onImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => void;
submitLabel: string;
imagePreview?: string;
showDeleteButton?: boolean;
}
export default function ProductDialog({
open,
onOpenChange,
form,
onSubmit,
onDelete,
onImageUpload,
submitLabel,
imagePreview,
showDeleteButton = false
}: ProductDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="text-2xl font-semibold">
{submitLabel} Product
</DialogTitle>
</DialogHeader>
<form onSubmit={onSubmit} className="space-y-6">
{/* Main fields grid */}
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
{/* Name field */}
<div className="flex flex-col justify-between">
<Label htmlFor="name" className="mb-1">
Product Name
</Label>
<Input
id="name"
{...form.register("name")}
placeholder="Enter product name"
/>
{form.formState.errors.name && (
<p className="text-sm text-red-500">
{form.formState.errors.name.message}
</p>
)}
<div className="flex flex-col">
<Label htmlFor="price" className="mb-1">
Price
</Label>
<Input
id="price"
type="number"
{...form.register("price", { valueAsNumber: true })}
placeholder="0.00"
/>
</div>
<div className="flex flex-col">
<Label htmlFor="stock_quantity" className="mb-1">
Stock
</Label>
<Input
id="stock_quantity"
type="number"
{...form.register("stock_quantity", { valueAsNumber: true })}
placeholder="0"
/>
</div>
</div>
{/* Image upload & preview */}
<div className="flex flex-col items-center">
<Label htmlFor="image">Image</Label>
{imagePreview ? (
<img
src={imagePreview}
alt="Preview"
className="mb-2 h-48 w-48 rounded-lg border object-cover"
/>
) : (
<Placeholder className="h-48 w-48 fill-gray-100 dark:fill-slate-900 text-slate-900 dark:text-zinc-500" />
)}
<p className="text-xs text-muted-foreground select-none">
JPG or PNG only. Max size: 2MB.
</p>
<Input
id="image"
type="file"
accept="image/png, image/jpeg"
onChange={onImageUpload}
className="pt-1"
/>
</div>
{/* Description spanning full width */}
<div className="sm:col-span-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
{...form.register("description")}
placeholder="Short product description"
rows={4}
/>
{form.formState.errors.description && (
<p className="text-sm text-red-500">
{form.formState.errors.description.message}
</p>
)}
</div>
</div>
<Separator />
{/* Action buttons */}
<DialogFooter className="flex justify-end space-x-3">
{showDeleteButton && onDelete && (
<Button variant="destructive" type="button" onClick={onDelete}>
Delete
</Button>
)}
<Button type="submit">{submitLabel}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,93 @@
import { Button } from "@/components/ui/button";
import { DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { UseFormReturn } from "react-hook-form";
import { z } from "zod";
const schema = z.object({
name: z.string().min(1, "Name is required"),
description: z.string().min(1, "Description is required"),
price: z.coerce.number().min(0),
stock_quantity: z.coerce.number().int().min(0),
image_data: z.string().optional()
});
type ProductForm = z.infer<typeof schema>;
export function ProductForm({
onSubmit,
form,
imagePreview,
onImageUpload,
submitLabel
}: {
onSubmit: () => void;
form: UseFormReturn<typeof ProductForm>;
imagePreview?: string;
onImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => void;
submitLabel: string;
}) {
return (
<form onSubmit={onSubmit} className="grid gap-4 py-4">
<div className="grid gap-1">
<Label htmlFor="name">Product Name</Label>
<Input {...form.register("name")} placeholder="Product name" />
{form.formState.errors.name && (
<p className="text-sm text-red-500">
{form.formState.errors.name.message}
</p>
)}
</div>
<div className="grid gap-1">
<Label htmlFor="description">Description</Label>
<Input
{...form.register("description")}
placeholder="Short description"
/>
{form.formState.errors.description && (
<p className="text-sm text-red-500">
{form.formState.errors.description.message}
</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-1">
<Label htmlFor="price">Price</Label>
<Input
type="number"
{...form.register("price", { valueAsNumber: true })}
placeholder="0.00"
/>
</div>
<div className="grid gap-1">
<Label htmlFor="stock">Stock</Label>
<Input
type="number"
{...form.register("stock_quantity", { valueAsNumber: true })}
placeholder="0"
/>
</div>
</div>
<div className="grid gap-1">
<Label htmlFor="image">Image</Label>
<Input
id="image"
type="file"
accept="image/jpg,image/png"
onChange={onImageUpload}
/>
{form.watch("image_data") && (
<img
src={form.watch("image_data")}
alt="Preview"
className="mt-2 h-24 w-24 rounded border object-cover"
/>
)}
</div>
<DialogFooter>
<Button type="submit">{submitLabel}</Button>
</DialogFooter>
</form>
);
}

View File

@ -0,0 +1,60 @@
import { ProductWithDetails } from "@/api/mock/models";
import Placeholder from "@/assets/placeholder.svg?react";
import {
Card,
CardDescription,
CardHeader,
CardTitle
} from "@/components/ui/card";
export function ProductList({
products,
isLoading,
onClick
}: {
products: ProductWithDetails[];
isLoading: boolean;
onClick: (id: number) => void;
}) {
if (isLoading) return <p className="col-span-full">Loading...</p>;
return (
<ul className="faded-bottom no-scrollbar grid grid-cols-1 gap-4 overflow-auto pb-16 pt-4 sm:grid-cols-2 md:grid-cols-4 xl:grid-cols-6 2xl:grid-cols-8">
{products.map((product) => (
<Card
key={product.id}
onClick={() => onClick(product.id)}
className="cursor-pointer overflow-hidden">
<div className="relative aspect-square w-full overflow-hidden">
{product.images.length > 0 ? (
<img
src={
product.images.length > 0
? product.images[0].image_url
: "/images/placeholder.svg"
}
alt={product.name}
className="h-full w-full object-cover"
/>
) : (
<Placeholder className="h-full w-full object-cover fill-gray-100 dark:fill-slate-900 text-slate-900 dark:text-zinc-500" />
)}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-background/90 to-background/0 p-2">
<span className="rounded bg-secondary px-2 py-1 text-[10px] font-medium">
{product.stock_quantity} in stock
</span>
</div>
</div>
<CardHeader className="p-3 pb-2">
<CardTitle className="line-clamp-1 text-sm">
{product.name}
</CardTitle>
<CardDescription className="line-clamp-2 text-xs text-muted-foreground">
{product.description}
</CardDescription>
</CardHeader>
</Card>
))}
</ul>
);
}

View File

@ -1,110 +0,0 @@
import {
IconBrandDiscord,
IconBrandDocker,
IconBrandFigma,
IconBrandGithub,
IconBrandGitlab,
IconBrandGmail,
IconBrandMedium,
IconBrandNotion,
IconBrandSkype,
IconBrandSlack,
IconBrandStripe,
IconBrandTelegram,
IconBrandTrello,
IconBrandWhatsapp,
IconBrandZoom
} from "@tabler/icons-react";
export const apps = [
{
name: "Telegram",
logo: <IconBrandTelegram />,
connected: false,
desc: "Connect with Telegram for real-time communication."
},
{
name: "Notion",
logo: <IconBrandNotion />,
connected: true,
desc: "Effortlessly sync Notion pages for seamless collaboration."
},
{
name: "Figma",
logo: <IconBrandFigma />,
connected: true,
desc: "View and collaborate on Figma designs in one place."
},
{
name: "Trello",
logo: <IconBrandTrello />,
connected: false,
desc: "Sync Trello cards for streamlined project management."
},
{
name: "Slack",
logo: <IconBrandSlack />,
connected: false,
desc: "Integrate Slack for efficient team communication"
},
{
name: "Zoom",
logo: <IconBrandZoom />,
connected: true,
desc: "Host Zoom meetings directly from the dashboard."
},
{
name: "Stripe",
logo: <IconBrandStripe />,
connected: false,
desc: "Easily manage Stripe transactions and payments."
},
{
name: "Gmail",
logo: <IconBrandGmail />,
connected: true,
desc: "Access and manage Gmail messages effortlessly."
},
{
name: "Medium",
logo: <IconBrandMedium />,
connected: false,
desc: "Explore and share Medium stories on your dashboard."
},
{
name: "Skype",
logo: <IconBrandSkype />,
connected: false,
desc: "Connect with Skype contacts seamlessly."
},
{
name: "Docker",
logo: <IconBrandDocker />,
connected: false,
desc: "Effortlessly manage Docker containers on your dashboard."
},
{
name: "GitHub",
logo: <IconBrandGithub />,
connected: false,
desc: "Streamline code management with GitHub integration."
},
{
name: "GitLab",
logo: <IconBrandGitlab />,
connected: false,
desc: "Efficiently manage code projects with GitLab integration."
},
{
name: "Discord",
logo: <IconBrandDiscord />,
connected: false,
desc: "Connect with Discord for seamless team communication."
},
{
name: "WhatsApp",
logo: <IconBrandWhatsapp />,
connected: false,
desc: "Easily integrate WhatsApp for direct messaging."
}
];

View File

@ -0,0 +1,43 @@
import { ProductWithDetails } from "@/api/mock/models";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
export const schema = z.object({
name: z.string().min(1, "Name is required"),
description: z.string().min(1, "Description is required"),
price: z.coerce.number().min(0),
stock_quantity: z.coerce.number().int().min(0),
image_data: z.string().optional()
});
export type ProductForm = z.infer<typeof schema>;
export function useProductForm(productData?: ProductWithDetails | null) {
const form = useForm<ProductForm>({
resolver: zodResolver(schema),
defaultValues: {
name: "",
description: "",
price: 0,
stock_quantity: 0,
image_data: ""
}
});
useEffect(() => {
if (productData) {
form.reset({
name: productData.name,
description: productData.description,
price: productData.price,
stock_quantity: productData.stock_quantity,
image_data: productData.images[0]?.image_url || ""
});
}
}, [productData, form]);
return form;
}

View File

@ -1,55 +1,96 @@
import { useState } from "react";
import { import {
IconAdjustmentsHorizontal, IconAdjustmentsHorizontal,
IconSortAscendingLetters, IconSortAscendingLetters,
IconSortDescendingLetters IconSortDescendingLetters,
IconPlus
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { Header } from "@/components/layout/header";
import { Main } from "@/components/layout/main";
import { Search } from "@/components/search";
import { ThemeSwitch } from "@/components/theme-switch";
import { ProfileDropdown } from "@/components/profile-dropdown";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
Select, Select,
SelectContent,
SelectItem,
SelectTrigger, SelectTrigger,
SelectValue SelectValue,
SelectContent,
SelectItem
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Header } from "@/components/layout/header";
import { Main } from "@/components/layout/main";
import { ProfileDropdown } from "@/components/profile-dropdown";
import { Search } from "@/components/search";
import { ThemeSwitch } from "@/components/theme-switch";
import { apps } from "./data/apps";
const appText = new Map<string, string>([ import { useProducts } from "@/hooks/useProducts";
["all", "All Apps"], import { useProduct } from "@/hooks/useProduct";
["connected", "Connected"], import { useState } from "react";
["notConnected", "Not Connected"] import { useProductForm } from "./hooks/use-product-form";
]); import { ProductList } from "./components/product-list";
import { ProductDialog } from "./components/product-dialog";
import { toast } from "@/hooks/useToast";
export default function Products() { export default function Products() {
const [sort, setSort] = useState("ascending"); const [sort, setSort] = useState<"ascending" | "descending">("ascending");
const [appType, setAppType] = useState("all");
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [selectedProductId, setSelectedProductId] = useState<
number | undefined
>(undefined);
const filteredApps = apps const { data: products = [], isLoading } = useProducts();
.sort((a, b) => const { product, createProduct, updateProduct, deleteProduct } =
sort === "ascending" useProduct(selectedProductId);
? a.name.localeCompare(b.name)
: b.name.localeCompare(a.name) const form = useProductForm(product.data);
)
.filter((app) => const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
appType === "connected" const file = e.target.files?.[0];
? app.connected if (!file) return;
: appType === "notConnected" const reader = new FileReader();
? !app.connected reader.onload = () => form.setValue("image_data", reader.result as string);
: true reader.readAsDataURL(file);
) };
.filter((app) => app.name.toLowerCase().includes(searchTerm.toLowerCase()));
const handleCreateSubmit = form.handleSubmit((data) => {
form.reset({
name: "",
description: "",
price: 0,
stock_quantity: 0,
image_data: ""
});
createProduct.mutate(data);
setDialogOpen(false);
toast({ title: `${data.name} created`, variant: "default" });
});
const handleEditSubmit = form.handleSubmit((data) => {
if (!selectedProductId) return;
updateProduct.mutate(data);
setEditDialogOpen(false);
toast({ title: `${data.name} saved`, variant: "default" });
});
const handleDelete = () => {
if (!selectedProductId) return;
deleteProduct.mutate();
setEditDialogOpen(false);
toast({ title: `${product.data?.name} deleted`, variant: "destructive" });
};
const sortedProducts = [...products].sort((a, b) =>
sort === "ascending"
? a.name.localeCompare(b.name)
: b.name.localeCompare(a.name)
);
const filteredProducts = sortedProducts.filter((product) =>
product.name.toLowerCase().includes(searchTerm.toLowerCase())
);
return ( return (
<> <>
{/* ===== Top Heading ===== */}
<Header> <Header>
<Search /> <Search />
<div className="ml-auto flex items-center gap-4"> <div className="ml-auto flex items-center gap-4">
@ -58,37 +99,26 @@ export default function Products() {
</div> </div>
</Header> </Header>
{/* ===== Content ===== */}
<Main fixed> <Main fixed>
<div> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight"> <div>
Products <h1 className="text-2xl font-bold tracking-tight">Products</h1>
</h1> <p className="text-muted-foreground">
<p className="text-muted-foreground"> Here's a list of your products!
Here&apos;s a list of your products! </p>
</p>
</div>
<div className="my-4 flex items-end justify-between sm:my-0 sm:items-center">
<div className="flex flex-col gap-4 sm:my-4 sm:flex-row">
<Input
placeholder="Filter apps..."
className="h-9 w-40 lg:w-[250px]"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<Select value={appType} onValueChange={setAppType}>
<SelectTrigger className="w-36">
<SelectValue>{appText.get(appType)}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Apps</SelectItem>
<SelectItem value="connected">Connected</SelectItem>
<SelectItem value="notConnected">Not Connected</SelectItem>
</SelectContent>
</Select>
</div> </div>
</div>
<Select value={sort} onValueChange={setSort}> <div className="flex flex-col gap-4 sm:my-4 sm:flex-row">
<Input
placeholder="Search products..."
className="h-9 w-40 lg:w-[250px]"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<Select
value={sort}
onValueChange={(v) => setSort(v as "ascending" | "descending")}>
<SelectTrigger className="w-16"> <SelectTrigger className="w-16">
<SelectValue> <SelectValue>
<IconAdjustmentsHorizontal size={18} /> <IconAdjustmentsHorizontal size={18} />
@ -110,32 +140,49 @@ export default function Products() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<Separator className="shadow" /> <Separator className="shadow" />
<ul className="faded-bottom no-scrollbar grid gap-4 overflow-auto pb-16 pt-4 md:grid-cols-2 lg:grid-cols-3">
{filteredApps.map((app) => ( <ProductList
<li products={filteredProducts}
key={app.name} isLoading={isLoading}
className="rounded-lg border p-4 hover:shadow-md"> onClick={(id) => {
<div className="mb-8 flex items-center justify-between"> setSelectedProductId(id);
<div setEditDialogOpen(true);
className={`flex size-10 items-center justify-center rounded-lg bg-muted p-2`}> }}
{app.logo} />
</div>
<Button <Button
variant="outline" onClick={() => setDialogOpen(true)}
size="sm" className="absolute bottom-0 z-10 mb-4 size-14 rounded-full p-0 shadow-lg hover:brightness-110"
className={`${app.connected ? "border border-blue-300 bg-blue-50 hover:bg-blue-100 dark:border-blue-700 dark:bg-blue-950 dark:hover:bg-blue-900" : ""}`}> variant="default">
{app.connected ? "Connected" : "Connect"} <IconPlus size={28} />
</Button> </Button>
</div>
<div>
<h2 className="mb-1 font-semibold">{app.name}</h2>
<p className="line-clamp-2 text-gray-500">{app.desc}</p>
</div>
</li>
))}
</ul>
</Main> </Main>
{/* Create Dialog */}
<ProductDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
form={form}
onSubmit={handleCreateSubmit}
onImageUpload={handleImageUpload}
submitLabel="Create"
imagePreview={form.watch("image_data")}
/>
{/* Edit Dialog */}
<ProductDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
form={form}
onSubmit={handleEditSubmit}
onImageUpload={handleImageUpload}
onDelete={handleDelete}
submitLabel="Update"
imagePreview={form.watch("image_data")}
showDeleteButton
/>
</> </>
); );
} }

View File

@ -15,6 +15,9 @@ import { PhoneInput } from "@/components/ui/phone-number-input";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { useShop } from "@/hooks/useShop";
import { useEffect } from "react";
import { toast } from "@/hooks/useToast";
const shopAboutSchema = z.object({ const shopAboutSchema = z.object({
name: z.string().min(1, "Name is required"), name: z.string().min(1, "Name is required"),
@ -25,7 +28,7 @@ const shopAboutSchema = z.object({
address: z.object({ address: z.object({
street: z.string(), street: z.string(),
city: z.string(), city: z.string(),
state: z.string().optional(), state: z.string().nullable().optional(),
postal_code: z.string(), postal_code: z.string(),
country: z.string() country: z.string()
}), }),
@ -35,31 +38,24 @@ const shopAboutSchema = z.object({
type ShopAboutFormValues = z.infer<typeof shopAboutSchema>; type ShopAboutFormValues = z.infer<typeof shopAboutSchema>;
export function ShopAboutForm() { export function ShopAboutForm() {
const defaultValues: ShopAboutFormValues = { const { shop, isLoading, updateShop } = useShop();
name: "My Shop",
description: "This is a sample shop description.",
currency: "USD",
contact_email: "user@example.com",
contact_phone_number: "+266018975510",
address: {
street: "123 Main St",
city: "Metropolis",
state: "",
postal_code: "12345",
country: "USA"
},
status: "inactive"
};
const form = useForm<ShopAboutFormValues>({ const form = useForm<ShopAboutFormValues>({
resolver: zodResolver(shopAboutSchema), resolver: zodResolver(shopAboutSchema),
defaultValues, defaultValues: shop ?? undefined,
mode: "onChange" mode: "onChange"
}); });
useEffect(() => {
if (shop) form.reset(shop);
}, [shop, form]);
function onSubmit(data: ShopAboutFormValues) { function onSubmit(data: ShopAboutFormValues) {
console.log("Submitted shop about data:", data); updateShop(data);
toast({title: "Saved shop data"})
} }
if (isLoading) return <div className="p-4">Loading...</div>;
if (!shop) return <div className="p-4">No shop found</div>;
return ( return (
<ScrollArea <ScrollArea
@ -67,9 +63,7 @@ export function ShopAboutForm() {
type="always" type="always"
className="hidden w-full min-w-40 bg-background px-1 md:block"> className="hidden w-full min-w-40 bg-background px-1 md:block">
<Form {...form}> <Form {...form}>
<form <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-xl">Basic Details</CardTitle> <CardTitle className="text-xl">Basic Details</CardTitle>
@ -123,39 +117,37 @@ export function ShopAboutForm() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<FormField
<FormField control={form.control}
control={form.control} name="contact_email"
name="contact_email" render={({ field }) => (
render={({ field }) => ( <FormItem>
<FormItem> <FormLabel>Contact Email</FormLabel>
<FormLabel>Contact Email</FormLabel> <FormControl>
<FormControl> <Input
<Input type="email"
type="email" placeholder="user@example.com"
placeholder="user@example.com" {...field}
{...field} />
/> </FormControl>
</FormControl> <FormMessage />
<FormMessage /> </FormItem>
</FormItem> )}
)} />
/> <FormField
<FormField control={form.control}
control={form.control} name="contact_phone_number"
name="contact_phone_number" render={({ field }) => (
render={({ field }) => ( <FormItem>
<FormItem> <FormLabel>Phone Number</FormLabel>
<FormLabel>Phone Number</FormLabel> <FormControl>
<FormControl> <PhoneInput {...field} />
<PhoneInput {...field} /> </FormControl>
</FormControl> <FormMessage />
<FormMessage /> </FormItem>
</FormItem> )}
)} />
/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -198,7 +190,7 @@ export function ShopAboutForm() {
<FormItem> <FormItem>
<FormLabel>State/Province</FormLabel> <FormLabel>State/Province</FormLabel>
<FormControl> <FormControl>
<Input placeholder="State" {...field} /> <Input placeholder="State" {...field} value={field.value ?? ""} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>

16
frontend/src/utils/jwt.ts Normal file
View File

@ -0,0 +1,16 @@
export function generateFakeJWT(sub: string): string {
const header = btoa(JSON.stringify({ alg: "none", typ: "JWT" }));
const payload = btoa(JSON.stringify({ sub }));
const signature = "";
return `${header}.${payload}.${signature}`;
}
export function extractSubFromJWT(token: string): string | null {
try {
const [, payload] = token.split(".");
const decoded = JSON.parse(atob(payload));
return decoded.sub ?? null;
} catch {
return null;
}
}

View File

@ -1 +1,2 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
/// <reference types="vite-plugin-svgr/client" />

View File

@ -2,11 +2,13 @@ import path from "path"
import { defineConfig } from "vite" import { defineConfig } from "vite"
import viteReact from '@vitejs/plugin-react' import viteReact from '@vitejs/plugin-react'
import { TanStackRouterVite } from '@tanstack/router-plugin/vite' import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
import vitePluginSvgr from "vite-plugin-svgr";
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
TanStackRouterVite({ autoCodeSplitting: true }), TanStackRouterVite({ autoCodeSplitting: true }),
viteReact() viteReact(),
vitePluginSvgr()
], ],
resolve: { resolve: {
alias: { alias: {