Initial commit: fabric-trade-user Vue 3 app with mobile and desktop layouts
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
end_of_line = lf
|
||||
max_line_length = 100
|
||||
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Cypress
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Vitest
|
||||
__screenshots__/
|
||||
|
||||
# Vite
|
||||
*.timestamp-*-*.mjs
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue"],
|
||||
"env": {
|
||||
"browser": true
|
||||
},
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"EditorConfig.EditorConfig",
|
||||
"oxc.oxc-vscode",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# fabric-trade
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Recommended Browser Setup
|
||||
|
||||
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||
- Firefox:
|
||||
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
|
||||
```sh
|
||||
npm run lint
|
||||
```
|
||||
@@ -0,0 +1,26 @@
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import pluginOxlint from 'eslint-plugin-oxlint'
|
||||
import skipFormatting from 'eslint-config-prettier/flat'
|
||||
|
||||
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
||||
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
||||
|
||||
export default defineConfigWithVueTs(
|
||||
{
|
||||
name: 'app/files-to-lint',
|
||||
files: ['**/*.{vue,ts,mts,tsx}'],
|
||||
},
|
||||
|
||||
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||
|
||||
...pluginVue.configs['flat/essential'],
|
||||
vueTsConfigs.recommended,
|
||||
|
||||
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
|
||||
|
||||
skipFormatting,
|
||||
)
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#17379f">
|
||||
<title>سامانه سفارش عمده پارچه</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+5167
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "fabric-trade",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build",
|
||||
"lint": "run-s \"lint:*\"",
|
||||
"lint:oxlint": "oxlint . --fix",
|
||||
"lint:eslint": "eslint . --fix --cache",
|
||||
"format": "prettier --write --experimental-cli src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"jalaali-js": "^2.0.0",
|
||||
"pinia": "^4.0.2",
|
||||
"vue": "^3.5.40",
|
||||
"vue-router": "^5.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@types/node": "^24.13.3",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vitejs/plugin-vue-jsx": "^5.1.6",
|
||||
"@vue/eslint-config-typescript": "^14.9.0",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"eslint": "^10.7.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-oxlint": "~1.73.0",
|
||||
"eslint-plugin-vue": "~10.9.2",
|
||||
"jiti": "^2.7.0",
|
||||
"npm-run-all2": "^9.0.2",
|
||||
"oxlint": "~1.74.0",
|
||||
"prettier": "3.9.5",
|
||||
"typescript": "~6.0.0",
|
||||
"vite": "^8.1.5",
|
||||
"vite-plugin-vue-devtools": "^8.1.5",
|
||||
"vue-eslint-parser": "^10.4.1",
|
||||
"vue-tsc": "^3.3.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.12.0"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
+24
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import DefaultLayout from '@/layouts/DefaultLayout.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useCartStore } from '@/stores/cart'
|
||||
import { useOrderStore } from '@/stores/order'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const cartStore = useCartStore()
|
||||
const orderStore = useOrderStore()
|
||||
|
||||
onMounted(() => {
|
||||
authStore.hydrate()
|
||||
cartStore.hydrate()
|
||||
orderStore.hydrate()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DefaultLayout>
|
||||
<RouterView />
|
||||
</DefaultLayout>
|
||||
</template>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,128 @@
|
||||
/* Application shell — global (unscoped) layout breakpoints */
|
||||
|
||||
.app-shell {
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
background: var(--color-shell-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-layout {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-main__content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
max-width: var(--content-max-width);
|
||||
margin-inline: auto;
|
||||
padding-inline: var(--page-padding);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.app-content {
|
||||
padding-inline: var(--page-padding-tablet);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.app-content {
|
||||
padding-inline: var(--page-padding-desktop);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Mobile: full-width native app (< 768px) ── */
|
||||
.app-shell--with-nav {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.app-shell--with-nav .app-frame {
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ── Tablet + Desktop: sidebar + header shell (>= 768px) ── */
|
||||
@media (min-width: 768px) {
|
||||
.app-shell--with-nav {
|
||||
background: var(--color-page-background);
|
||||
}
|
||||
|
||||
.app-shell--with-nav .app-frame {
|
||||
background: var(--color-page-background);
|
||||
}
|
||||
|
||||
.app-shell--with-nav .app-main__content {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Guest pages (login, checkout) ── */
|
||||
@media (min-width: 768px) {
|
||||
.app-shell--no-nav {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: var(--color-page-background);
|
||||
}
|
||||
|
||||
.app-shell--no-nav .app-layout {
|
||||
max-width: var(--form-max-width);
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: calc(100dvh - 48px);
|
||||
}
|
||||
|
||||
.app-shell--no-nav .app-frame {
|
||||
max-width: var(--form-max-width);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-frame);
|
||||
max-height: calc(100dvh - 48px);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.app-shell--no-nav.app-shell--wide .app-layout,
|
||||
.app-shell--no-nav.app-shell--wide .app-frame {
|
||||
max-width: min(var(--content-max-width), calc(100vw - 48px));
|
||||
}
|
||||
|
||||
.app-shell--no-nav.app-shell--wide .app-frame {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/* Desktop page composition — >= 768px shell, >= 1200px full desktop */
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.desktop-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
max-width: var(--content-max-width);
|
||||
margin-inline: auto;
|
||||
padding: 20px 0 28px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.desktop-page__head {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.desktop-page__title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1.375rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.desktop-page__subtitle {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.desktop-two-col {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) var(--summary-panel-width, 320px);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.desktop-two-col__main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.desktop-summary-panel {
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
padding: 20px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.desktop-summary-panel__title {
|
||||
margin: 0 0 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.desktop-summary-panel__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.desktop-summary-panel__row span:first-child {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.desktop-summary-panel__row strong {
|
||||
color: var(--color-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.desktop-summary-panel__action {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.desktop-table-wrap {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.desktop-table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.desktop-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
min-width: 640px;
|
||||
}
|
||||
|
||||
.desktop-table th {
|
||||
padding: 12px 16px;
|
||||
text-align: start;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.desktop-table td {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
color: var(--color-text);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.desktop-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.desktop-table tbody tr:hover td {
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.desktop-grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.desktop-grid-3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.desktop-grid-4 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.desktop-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.desktop-toolbar__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.desktop-btn-compact {
|
||||
width: auto !important;
|
||||
min-width: 120px;
|
||||
padding-inline: 20px !important;
|
||||
}
|
||||
|
||||
.desktop-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.desktop-form-grid__full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.desktop-checkout-shell {
|
||||
width: 100%;
|
||||
max-width: min(var(--content-max-width), calc(100vw - 48px));
|
||||
margin-inline: auto;
|
||||
padding: 24px 0 32px;
|
||||
}
|
||||
|
||||
.desktop-settlement-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(280px, 380px));
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
max-width: 820px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.desktop-company-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(280px, 400px));
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
max-width: 900px;
|
||||
margin-inline: auto;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.desktop-page {
|
||||
padding: 24px 0 32px;
|
||||
}
|
||||
|
||||
.desktop-page__head {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.desktop-grid-3 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.desktop-grid-4 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1440px) {
|
||||
.desktop-grid-3 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,652 @@
|
||||
/* App shell page layouts — 768px and above */
|
||||
|
||||
@media (min-width: 768px) {
|
||||
:root {
|
||||
--page-padding: var(--page-padding-tablet);
|
||||
--nav-height: 0px;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: 20px var(--page-padding-tablet) 28px;
|
||||
background: transparent;
|
||||
max-width: var(--content-max-width);
|
||||
margin-inline: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page__scroll {
|
||||
overflow: visible;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.page__scroll--with-nav {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
/* Shared page headings */
|
||||
.page-head {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-head__title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.page-head__subtitle {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Orders dashboard */
|
||||
.orders-page__desktop-head {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.orders-page__desktop-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1.375rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.orders-page__desktop-subtitle {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.orders-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.orders-stat-card {
|
||||
padding: 16px 18px 14px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.orders-stat-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.orders-stat-card__label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.orders-stat-card__label--total,
|
||||
.orders-stat-card__label--review {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.orders-stat-card__label--active {
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
.orders-stat-card__label--done {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.orders-stat-card__value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
line-height: 1.15;
|
||||
margin: 0 0 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.orders-stat-card__link {
|
||||
margin-top: auto;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-primary);
|
||||
font-family: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.orders-stat-card__link:hover {
|
||||
color: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.orders-stat-card__icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.orders-stat-card__icon--total {
|
||||
background: var(--color-primary-soft);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.orders-stat-card__icon--review {
|
||||
background: var(--color-primary-soft);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.orders-stat-card__icon--active {
|
||||
background: var(--color-warning-light);
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
.orders-stat-card__icon--done {
|
||||
background: var(--color-success-soft);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.orders-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.orders-toolbar__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.orders-toolbar__filter-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 40px;
|
||||
padding: 0 18px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.orders-toolbar__filter-btn:hover {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.orders-toolbar__layout-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.orders-toolbar__layout-btn:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.orders-toolbar__pills {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.orders-filter-pill {
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-muted);
|
||||
font-family: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
|
||||
.orders-filter-pill:hover {
|
||||
border-color: #cbd5e1;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.orders-filter-pill--active {
|
||||
background: var(--color-primary-soft);
|
||||
border-color: #c7d7ff;
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.orders-table-wrap {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.orders-table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.orders-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
min-width: 720px;
|
||||
}
|
||||
|
||||
.orders-table th {
|
||||
padding: 14px 16px;
|
||||
text-align: start;
|
||||
font-weight: 600;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.orders-table td {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
color: var(--color-text);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.orders-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.orders-table tbody tr:hover td {
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.orders-table__number {
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.orders-table__amount {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.orders-table__date {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8125rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.orders-table__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.orders-table__action {
|
||||
padding: 7px 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-primary);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.orders-table__action:hover {
|
||||
background: var(--color-primary-soft);
|
||||
border-color: #c7d7ff;
|
||||
}
|
||||
|
||||
.orders-table__menu {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.orders-table__menu:hover {
|
||||
background: #f1f5f9;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.orders-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.orders-pagination__summary {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.orders-pagination__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.orders-pagination__arrow {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.orders-pagination__arrow:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.orders-pagination__arrow:not(:disabled):hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.orders-pagination__arrow-icon--prev {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.orders-pagination__arrow-icon--next {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.orders-pagination__ellipsis {
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.orders-pagination__page {
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.orders-pagination__page:hover:not(.orders-pagination__page--active) {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.orders-pagination__page--active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Product page desktop layout */
|
||||
.product-page__layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 300px;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.product-page__main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.product-page__sidebar {
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
}
|
||||
|
||||
.cart-summary-panel {
|
||||
padding: 20px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.cart-summary-panel__title {
|
||||
margin: 0 0 16px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.cart-summary-panel__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.cart-summary-panel__row span:first-child {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.cart-summary-panel__row strong {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.cart-summary-panel__btn {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.product-page__scroll {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.product-page .sticky-footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.product-list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
/* Cart desktop */
|
||||
.cart-page {
|
||||
max-width: var(--content-max-width);
|
||||
margin-inline: auto;
|
||||
padding: 20px var(--page-padding-tablet) 28px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cart-page__desktop-title {
|
||||
margin: 0 0 16px;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cart-page__body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 320px);
|
||||
gap: 20px 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.cart-page__company {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* Order detail desktop */
|
||||
.order-detail-page {
|
||||
max-width: var(--content-max-width);
|
||||
margin-inline: auto;
|
||||
padding: 24px var(--page-padding-desktop) 32px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.order-detail-page__tracking {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 340px;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.order-detail-page__tracking > .status-hero {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.order-detail-page__tracking > .order-detail-back {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.order-detail-page__tracking > .tracking-card:first-of-type {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.order-detail-page__tracking > .tracking-summary {
|
||||
grid-column: 2;
|
||||
grid-row: 2 / span 2;
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.order-detail-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 20px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-primary);
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Company select desktop */
|
||||
.company-select-page {
|
||||
max-width: var(--content-max-width);
|
||||
margin-inline: auto;
|
||||
padding: 24px var(--page-padding-desktop);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Catalog desktop */
|
||||
.catalog-page {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.catalog-list {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
/* Profile desktop */
|
||||
.profile-page {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.profile-hero {
|
||||
border-radius: var(--radius-md);
|
||||
margin-inline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
:root {
|
||||
--page-padding: var(--page-padding-desktop);
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: 24px var(--page-padding-desktop) 32px;
|
||||
}
|
||||
|
||||
.orders-stats {
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.orders-page__desktop-head {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.orders-toolbar {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.catalog-list {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.product-list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
@font-face {
|
||||
font-family: 'IRANSans';
|
||||
src: url('../fonts/iranSans/woff2/IRANSansWeb(FaNum).woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IRANSans';
|
||||
src: url('../fonts/iranSans/woff2/IRANSansWeb(FaNum)_Light.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IRANSans';
|
||||
src: url('../fonts/iranSans/woff2/IRANSansWeb(FaNum)_UltraLight.woff2') format('woff2');
|
||||
font-weight: 200;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IRANSans';
|
||||
src: url('../fonts/iranSans/woff2/IRANSansWeb(FaNum)_Medium.woff2') format('woff2');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IRANSans';
|
||||
src: url('../fonts/iranSans/woff2/IRANSansWeb(FaNum)_Medium.woff2') format('woff2');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IRANSans';
|
||||
src: url('../fonts/iranSans/woff2/IRANSansWeb(FaNum)_Bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IRANSans';
|
||||
src: url('../fonts/iranSans/woff2/IRANSansWeb(FaNum)_Black.woff2') format('woff2');
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
:root {
|
||||
--color-primary: #17379f;
|
||||
--color-primary-hover: #102b83;
|
||||
--color-primary-soft: #eef3ff;
|
||||
--color-primary-dark: #102b83;
|
||||
--color-primary-light: #eef3ff;
|
||||
--color-success: #159660;
|
||||
--color-success-light: #eaf8f1;
|
||||
--color-success-soft: #eaf8f1;
|
||||
--color-warning: #f59e0b;
|
||||
--color-warning-light: #fff6e5;
|
||||
--color-danger: #dc3545;
|
||||
--color-danger-light: #fee2e2;
|
||||
--color-purple: #7c3aed;
|
||||
--color-purple-light: #ede9fe;
|
||||
--color-info: #17379f;
|
||||
--color-info-light: #eef3ff;
|
||||
--color-bg: #f6f7fb;
|
||||
--color-page-background: #f6f7fb;
|
||||
--color-shell-bg: #eef0f5;
|
||||
--color-surface: #ffffff;
|
||||
--color-border: #e5e8ef;
|
||||
--color-text: #182033;
|
||||
--color-text-primary: #182033;
|
||||
--color-text-muted: #667085;
|
||||
--color-text-secondary: #667085;
|
||||
--color-tab-inactive-bg: #f1f5f9;
|
||||
--color-tab-inactive-text: #64748b;
|
||||
--color-summary-bg: #f1f5f9;
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.04);
|
||||
--shadow-md: 0 4px 16px rgba(16, 24, 40, 0.06);
|
||||
--shadow-card: 0 1px 2px rgba(16, 24, 40, 0.04), 0 4px 16px rgba(16, 24, 40, 0.04);
|
||||
--shadow-frame: 0 8px 32px rgba(15, 23, 42, 0.1);
|
||||
--nav-height: 64px;
|
||||
--page-padding: 16px;
|
||||
--page-padding-tablet: 20px;
|
||||
--page-padding-desktop: 28px;
|
||||
--page-gap: 12px;
|
||||
--content-max-width: 1360px;
|
||||
--form-max-width: 520px;
|
||||
--auth-form-max-width: 440px;
|
||||
--summary-panel-width: 320px;
|
||||
--sidebar-width: 240px;
|
||||
--sidebar-width-compact: 72px;
|
||||
--header-height: 70px;
|
||||
--app-shell-breakpoint: 768px;
|
||||
--font-family: 'IRANSans', 'Tahoma', sans-serif;
|
||||
--btn-height: 48px;
|
||||
--desktop-breakpoint: 1200px;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-family);
|
||||
background: var(--color-shell-bg);
|
||||
color: var(--color-text);
|
||||
line-height: 1.5;
|
||||
direction: rtl;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
input[type='number']::-webkit-inner-spin-button,
|
||||
input[type='number']::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input[type='number'] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 12px var(--page-padding) 0;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.page__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.page__scroll--with-nav {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
margin: 0 0 4px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 16px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.card + .card {
|
||||
margin-top: var(--page-gap);
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
border: 1px solid #cbd5e1;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background: var(--color-warning-light);
|
||||
color: #9a3412;
|
||||
border: 1px solid #fed7aa;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: var(--color-success-light);
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background: var(--color-danger-light);
|
||||
color: #991b1b;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--color-border);
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sticky-footer {
|
||||
position: fixed;
|
||||
bottom: var(--nav-height);
|
||||
inset-inline: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
z-index: 50;
|
||||
padding-inline: var(--page-padding);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sticky-footer-inner {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.no-nav .page {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.no-nav .sticky-footer {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.dialog-overlay .dialog {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* Responsive visibility */
|
||||
.mobile-only,
|
||||
.mobile-block {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.desktop-only {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mobile-hidden {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.desktop-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.mobile-only,
|
||||
.mobile-block {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.desktop-only {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.mobile-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.desktop-hidden {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.desktop-only-flex {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.desktop-only-grid {
|
||||
display: grid !important;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.page__scroll--with-nav {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
/* Responsive breakpoints:
|
||||
Mobile: < 768px
|
||||
Tablet: 768px – 1199px
|
||||
Desktop: >= 1200px
|
||||
Large desktop: >= 1440px
|
||||
*/
|
||||
|
||||
@media (min-width: 768px) {
|
||||
:root {
|
||||
--page-padding: var(--page-padding-tablet);
|
||||
--nav-height: 0px;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
overflow: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #cbd5e1 transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.sticky-footer {
|
||||
bottom: 0;
|
||||
max-width: min(var(--content-max-width), calc(100% - 2 * var(--page-padding)));
|
||||
}
|
||||
|
||||
.no-nav .sticky-footer {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
/* Auth pages */
|
||||
.auth-page__scroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 48px;
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
|
||||
.auth-header,
|
||||
.auth-page .card,
|
||||
.auth-footer {
|
||||
width: 100%;
|
||||
max-width: var(--form-max-width);
|
||||
}
|
||||
|
||||
/* Company selection */
|
||||
.company-select-page__list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 20px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.company-brand-card {
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
/* Catalog & product grids */
|
||||
.catalog-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--page-gap);
|
||||
}
|
||||
|
||||
.product-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--page-gap);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.product-page__scroll {
|
||||
padding-bottom: 88px;
|
||||
}
|
||||
|
||||
/* Settlement & check mode */
|
||||
.settlement-options,
|
||||
.mode-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 20px;
|
||||
max-width: 720px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* Company page container */
|
||||
.company-select-page {
|
||||
max-width: var(--content-max-width);
|
||||
margin-inline: auto;
|
||||
width: 100%;
|
||||
padding-inline: var(--page-padding);
|
||||
}
|
||||
|
||||
.company-select-page__header {
|
||||
max-width: 560px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* Catalog & orders page containers */
|
||||
.catalog-page .page__scroll,
|
||||
.orders-page__scroll,
|
||||
.product-page .product-page__scroll {
|
||||
max-width: var(--content-max-width);
|
||||
margin-inline: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Cart two-column layout */
|
||||
.cart-page {
|
||||
max-width: var(--content-max-width);
|
||||
margin-inline: auto;
|
||||
width: 100%;
|
||||
padding-inline: var(--page-padding);
|
||||
}
|
||||
|
||||
.cart-page__body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(260px, 300px);
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 16px 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.cart-page__company {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 0.9375rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.cart-page__scroll {
|
||||
grid-column: 1;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.cart-page__footer {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / -1;
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
border-top: none;
|
||||
box-shadow: var(--shadow-md);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.cart-table__head {
|
||||
position: static;
|
||||
}
|
||||
|
||||
/* Check list */
|
||||
.check-list-page {
|
||||
max-width: var(--content-max-width);
|
||||
margin-inline: auto;
|
||||
width: 100%;
|
||||
padding-inline: var(--page-padding);
|
||||
}
|
||||
|
||||
.check-list-page__scroll {
|
||||
max-width: var(--content-max-width);
|
||||
margin-inline: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.summary-cards {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.checks-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.rass-panel {
|
||||
max-width: 480px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* Profile */
|
||||
.profile-page__scroll {
|
||||
max-width: var(--content-max-width);
|
||||
margin-inline: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-menu {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.profile-menu__item {
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* Order detail & tracking */
|
||||
.order-detail-page__body {
|
||||
max-width: var(--content-max-width);
|
||||
margin-inline: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.order-detail-page__tracking {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 340px);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.order-detail-page__tracking > .status-hero {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.order-detail-page__tracking > .tracking-card {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.order-detail-page__tracking > .tracking-summary {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.order-detail-page__footer {
|
||||
max-width: var(--content-max-width);
|
||||
margin-inline: auto;
|
||||
width: 100%;
|
||||
padding-inline: var(--page-padding);
|
||||
}
|
||||
|
||||
/* Checkout forms */
|
||||
.checkout-page__scroll,
|
||||
.cash-page__scroll,
|
||||
.settlement-page__scroll,
|
||||
.check-mode-page__scroll {
|
||||
max-width: var(--form-max-width);
|
||||
margin-inline: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Cash settlement desktop */
|
||||
.cash-page {
|
||||
max-width: var(--content-max-width);
|
||||
margin-inline: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cash-page__footer {
|
||||
max-width: var(--form-max-width);
|
||||
margin-inline: auto;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
border-top: none;
|
||||
box-shadow: none;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.accounts-panel {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bank-card + .bank-card {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.cash-page__submit {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.order-info-card {
|
||||
max-width: 520px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* Profile edit */
|
||||
.profile-edit-page .page__scroll {
|
||||
max-width: var(--form-max-width);
|
||||
margin-inline: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Success page */
|
||||
.success-page__scroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
max-width: var(--form-max-width);
|
||||
margin-inline: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Dialogs */
|
||||
.dialog-overlay .dialog,
|
||||
.dialog-overlay .card.dialog {
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
:root {
|
||||
--page-padding: var(--page-padding-desktop);
|
||||
}
|
||||
|
||||
.catalog-list {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.checks-list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1440px) {
|
||||
.product-list {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1920px) {
|
||||
:root {
|
||||
--content-max-width: 1120px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 20 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.73 21a2 2 0 01-3.46 0" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 24 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :width="size" :height="size" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M13 2 5 14h6l-1 8 8-12h-6l1-8Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 24 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :width="size" :height="size" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M4 21V8l2-4h12l2 4v13H4Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path d="M4 11h16" stroke="currentColor" stroke-width="1.75" />
|
||||
<path
|
||||
d="M9 7V5a3 3 0 0 1 6 0v2"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
}>(),
|
||||
{ size: 24, strokeWidth: 1.75 },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M3 4h2l1.5 9h11L19 4h2"
|
||||
stroke="currentColor"
|
||||
:stroke-width="strokeWidth"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="9" cy="19" r="1.5" fill="currentColor" />
|
||||
<circle cx="17" cy="19" r="1.5" fill="currentColor" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 32 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :width="size" :height="size" viewBox="0 0 32 32" fill="none" aria-hidden="true">
|
||||
<rect
|
||||
x="4"
|
||||
y="8"
|
||||
width="24"
|
||||
height="16"
|
||||
rx="3"
|
||||
fill="#d4edda"
|
||||
stroke="#28a745"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<circle cx="16" cy="16" r="6" fill="#28a745" />
|
||||
<path
|
||||
d="M13 16l2 2 4-4"
|
||||
stroke="#fff"
|
||||
stroke-width="1.75"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 32 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :width="size" :height="size" viewBox="0 0 32 32" fill="none" aria-hidden="true">
|
||||
<rect
|
||||
x="3"
|
||||
y="7"
|
||||
width="26"
|
||||
height="18"
|
||||
rx="3"
|
||||
fill="#d4edda"
|
||||
stroke="#28a745"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<circle cx="16" cy="16" r="5" stroke="#28a745" stroke-width="1.5" />
|
||||
<path d="M16 13v6M13 16h6" stroke="#28a745" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 24 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :width="size" :height="size" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<rect
|
||||
x="4"
|
||||
y="3"
|
||||
width="16"
|
||||
height="18"
|
||||
rx="2"
|
||||
fill="#e8eef4"
|
||||
stroke="#1d3557"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<path d="M8 8h8M8 12h8M8 16h5" stroke="#1d3557" stroke-width="1.25" stroke-linecap="round" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 32 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :width="size" :height="size" viewBox="0 0 32 32" fill="none" aria-hidden="true">
|
||||
<rect
|
||||
x="4"
|
||||
y="6"
|
||||
width="24"
|
||||
height="20"
|
||||
rx="3"
|
||||
fill="#e8eef4"
|
||||
stroke="#1d3557"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<path d="M8 12h16M8 16h12M8 20h8" stroke="#1d3557" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
}>(),
|
||||
{ size: 22, strokeWidth: 1.75 },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" :stroke-width="strokeWidth" />
|
||||
<path
|
||||
d="M8 12.5l2.5 2.5L16 9.5"
|
||||
stroke="currentColor"
|
||||
:stroke-width="strokeWidth"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
}>(),
|
||||
{ size: 20, strokeWidth: 2 },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M9 6l6 6-6 6"
|
||||
stroke="currentColor"
|
||||
:stroke-width="strokeWidth"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
}>(),
|
||||
{ size: 18, strokeWidth: 2 },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M15 6l-6 6 6 6"
|
||||
stroke="currentColor"
|
||||
:stroke-width="strokeWidth"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
size?: number
|
||||
}>(),
|
||||
{ size: 64 },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
class="clipboard-icon"
|
||||
>
|
||||
<rect
|
||||
x="14"
|
||||
y="8"
|
||||
width="36"
|
||||
height="48"
|
||||
rx="4"
|
||||
fill="#E8D5B5"
|
||||
stroke="#C4A574"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<rect
|
||||
x="24"
|
||||
y="4"
|
||||
width="16"
|
||||
height="8"
|
||||
rx="2"
|
||||
fill="#94A3B8"
|
||||
stroke="#64748B"
|
||||
stroke-width="1"
|
||||
/>
|
||||
<line
|
||||
x1="22"
|
||||
y1="24"
|
||||
x2="42"
|
||||
y2="24"
|
||||
stroke="#94A3B8"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="22"
|
||||
y1="32"
|
||||
x2="42"
|
||||
y2="32"
|
||||
stroke="#94A3B8"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="22"
|
||||
y1="40"
|
||||
x2="36"
|
||||
y2="40"
|
||||
stroke="#94A3B8"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.clipboard-icon {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 28 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M14 4l2.5 5 5.5.8-4 3.9.9 5.5L14 16.8 8.1 19.2l.9-5.5-4-3.9 5.5-.8L14 4Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 32 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M19.5 5.5l3 3-11 11-4 1 1-4 11-11z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path d="M17 8l3 3" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" />
|
||||
<path
|
||||
d="M7 25h18"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 72 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 72 72"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="14" y="14" width="28" height="28" rx="4" fill="currentColor" transform="rotate(12 28 28)" />
|
||||
<rect x="30" y="30" width="28" height="28" rx="4" fill="currentColor" transform="rotate(-12 44 44)" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 28 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M14 3c-1.5 0-2.5 1-2.5 2.2 0 .4.1.8.3 1.1-2 .5-3.5 2.2-3.5 4.3v1.2c0 .6.4 1 1 1h9.4c.6 0 1-.4 1-1v-1.2c0-2.1-1.5-3.8-3.5-4.3.2-.3.3-.7.3-1.1C16.5 4 15.5 3 14 3Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M8 12.8v8.5c0 1.2 1 2.2 2.2 2.2h7.6c1.2 0 2.2-1 2.2-2.2v-8.5H8Z"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.2"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
}>(),
|
||||
{ size: 20, strokeWidth: 1.75 },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M4 6h16M7 12h10M10 18h4"
|
||||
stroke="currentColor"
|
||||
:stroke-width="strokeWidth"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 24 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :width="size" :height="size" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M4 14v-2a8 8 0 0 1 16 0v2"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<rect x="2" y="14" width="4" height="6" rx="2" stroke="currentColor" stroke-width="1.75" />
|
||||
<rect x="18" y="14" width="4" height="6" rx="2" stroke="currentColor" stroke-width="1.75" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
}>(),
|
||||
{ size: 24, strokeWidth: 1.75 },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M3 10.5L12 4l9 6.5V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1v-9.5Z"
|
||||
stroke="currentColor"
|
||||
:stroke-width="strokeWidth"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
}>(),
|
||||
{ size: 22, strokeWidth: 1.75 },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M6 3h12M6 21h12M8 3v4.5a4 4 0 0 0 2.5 3.7V14a4 4 0 0 0-2.5 3.7V21M16 3v4.5a4 4 0 0 1-2.5 3.7V14a4 4 0 0 1 2.5 3.7V21"
|
||||
stroke="currentColor"
|
||||
:stroke-width="strokeWidth"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 24 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :width="size" :height="size" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.75" />
|
||||
<path d="M8 9V7a4 4 0 1 1 8 0v2" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" />
|
||||
<path d="M7 12h10" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
}>(),
|
||||
{ size: 20, strokeWidth: 1.75 },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="4" y="4" width="7" height="7" rx="1.5" stroke="currentColor" :stroke-width="strokeWidth" />
|
||||
<rect x="13" y="4" width="7" height="7" rx="1.5" stroke="currentColor" :stroke-width="strokeWidth" />
|
||||
<rect x="4" y="13" width="7" height="7" rx="1.5" stroke="currentColor" :stroke-width="strokeWidth" />
|
||||
<rect x="13" y="13" width="7" height="7" rx="1.5" stroke="currentColor" :stroke-width="strokeWidth" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 24 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :width="size" :height="size" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M9 18h6"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M10 22h4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 2a5 5 0 0 0-2.4 9.3V14h4.8v-2.7A5 5 0 0 0 12 2Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 24 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :width="size" :height="size" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<rect x="5" y="11" width="14" height="10" rx="2" stroke="currentColor" stroke-width="1.75" />
|
||||
<path
|
||||
d="M8 11V8a4 4 0 1 1 8 0v3"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 24 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :width="size" :height="size" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<rect x="3" y="6" width="18" height="12" rx="2" stroke="currentColor" stroke-width="1.75" />
|
||||
<path d="M3 8l9 6 9-6" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 24 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :width="size" :height="size" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M12 21s6-5.2 6-10a6 6 0 1 0-12 0c0 4.8 6 10 6 10Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="11" r="2" stroke="currentColor" stroke-width="1.75" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
size?: number
|
||||
}>(),
|
||||
{ size: 20 },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="5" r="1.5" fill="currentColor" />
|
||||
<circle cx="12" cy="12" r="1.5" fill="currentColor" />
|
||||
<circle cx="12" cy="19" r="1.5" fill="currentColor" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
}>(),
|
||||
{ size: 24, strokeWidth: 1.75 },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect
|
||||
x="5"
|
||||
y="3"
|
||||
width="14"
|
||||
height="18"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
:stroke-width="strokeWidth"
|
||||
/>
|
||||
<path
|
||||
d="M9 7h6M9 11h6M9 15h4"
|
||||
stroke="currentColor"
|
||||
:stroke-width="strokeWidth"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M8 3V2a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v1"
|
||||
stroke="currentColor"
|
||||
:stroke-width="strokeWidth"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 72 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 72 72"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<text
|
||||
x="36"
|
||||
y="58"
|
||||
text-anchor="middle"
|
||||
fill="currentColor"
|
||||
font-family="IRANSans, Tahoma, sans-serif"
|
||||
font-size="56"
|
||||
font-weight="700"
|
||||
>
|
||||
پ
|
||||
</text>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 24 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :width="size" :height="size" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M8.5 4h7l1 4.5-3 1.5a12 12 0 0 0 4.5 4.5l1.5-3L21 15v7h-2.5A14.5 14.5 0 0 1 3 6.5V4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
}>(),
|
||||
{ size: 24, strokeWidth: 1.75 },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="8" r="4" stroke="currentColor" :stroke-width="strokeWidth" />
|
||||
<path
|
||||
d="M5 21v-1a5 5 0 0 1 5-5h4a5 5 0 0 1 5 5v1"
|
||||
stroke="currentColor"
|
||||
:stroke-width="strokeWidth"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
}>(),
|
||||
{ size: 22, strokeWidth: 1.75 },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" :stroke-width="strokeWidth" />
|
||||
<path
|
||||
d="M12 7v5l3 2"
|
||||
stroke="currentColor"
|
||||
:stroke-width="strokeWidth"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 20 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="M20 20l-3.5-3.5" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 24 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :width="size" :height="size" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.75" />
|
||||
<path
|
||||
d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M18.4 5.6 17 7M7 17l-1.4 1.4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 24 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :width="size" :height="size" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M8 8V6a4 4 0 1 1 8 0v2"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M6 8h12l-1 12H7L6 8Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 48 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :width="size" :height="size" viewBox="0 0 48 48" fill="none" aria-hidden="true">
|
||||
<circle cx="24" cy="24" r="22" fill="#d4edda" />
|
||||
<circle cx="24" cy="24" r="14" fill="#28a745" />
|
||||
<path
|
||||
d="M17 24l4 4 8-8"
|
||||
stroke="#fff"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
}>(),
|
||||
{ size: 20, strokeWidth: 1.5 },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect
|
||||
x="10"
|
||||
y="4"
|
||||
width="4"
|
||||
height="2"
|
||||
rx="0.5"
|
||||
stroke="currentColor"
|
||||
:stroke-width="strokeWidth"
|
||||
/>
|
||||
<path
|
||||
d="M6.5 8h11"
|
||||
stroke="currentColor"
|
||||
:stroke-width="strokeWidth"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M8.5 8.5h7v10.75c0 .69-.56 1.25-1.25 1.25h-4.5c-.69 0-1.25-.56-1.25-1.25V8.5Z"
|
||||
stroke="currentColor"
|
||||
:stroke-width="strokeWidth"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M10.75 11.5v6.25M13.25 11.5v6.25"
|
||||
stroke="currentColor"
|
||||
:stroke-width="strokeWidth"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 24 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :width="size" :height="size" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" fill="currentColor" />
|
||||
<path
|
||||
d="M8 12.2 10.8 15 16 9.8"
|
||||
stroke="#fff"
|
||||
stroke-width="1.75"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import DefaultLayout from '@/layouts/DefaultLayout.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DefaultLayout>
|
||||
<slot />
|
||||
</DefaultLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useCartStore } from '@/stores/cart'
|
||||
import { MAIN_NAV_ITEMS, isNavItemActive } from '@/utils/navigation'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const cartStore = useCartStore()
|
||||
|
||||
const cartCount = computed(() => cartStore.itemCount)
|
||||
|
||||
function navigate(path: string) {
|
||||
router.push(path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="bottom-nav" aria-label="ناوبری اصلی">
|
||||
<button
|
||||
v-for="item in MAIN_NAV_ITEMS"
|
||||
:key="item.name"
|
||||
type="button"
|
||||
class="nav-item"
|
||||
:class="{ active: isNavItemActive(item.name, item.path, route.path) }"
|
||||
:aria-current="isNavItemActive(item.name, item.path, route.path) ? 'page' : undefined"
|
||||
@click="navigate(item.path)"
|
||||
>
|
||||
<span class="nav-icon">
|
||||
<component :is="item.icon" :size="22" />
|
||||
</span>
|
||||
<span class="nav-label">{{ item.label }}</span>
|
||||
<span v-if="item.name === 'cart' && cartCount > 0" class="nav-badge">{{ cartCount }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bottom-nav {
|
||||
flex-shrink: 0;
|
||||
height: var(--nav-height);
|
||||
background: var(--color-surface);
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: stretch;
|
||||
z-index: 100;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.bottom-nav {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 8px 4px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
position: relative;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.nav-badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
inset-inline-start: 50%;
|
||||
margin-inline-start: 10px;
|
||||
background: var(--color-danger);
|
||||
color: #fff;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
currentStep: 1 | 2 | 3 | 4
|
||||
}>()
|
||||
|
||||
const steps = [
|
||||
{ num: 1, label: 'سبد خرید' },
|
||||
{ num: 2, label: 'انتخاب تسویه' },
|
||||
{ num: 3, label: 'ثبت اطلاعات' },
|
||||
{ num: 4, label: 'تأیید نهایی' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="checkout-stepper" aria-label="مراحل تسویه">
|
||||
<template v-for="(step, index) in steps" :key="step.num">
|
||||
<div
|
||||
class="checkout-stepper__item"
|
||||
:class="{
|
||||
'checkout-stepper__item--done': step.num < currentStep,
|
||||
'checkout-stepper__item--active': step.num === currentStep,
|
||||
}"
|
||||
>
|
||||
<span class="checkout-stepper__dot">
|
||||
<span v-if="step.num < currentStep" class="checkout-stepper__check">✓</span>
|
||||
<template v-else>{{ step.num }}</template>
|
||||
</span>
|
||||
<span class="checkout-stepper__label">{{ step.label }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="index < steps.length - 1"
|
||||
class="checkout-stepper__bridge"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
class="checkout-stepper__bridge-line"
|
||||
:class="{ 'checkout-stepper__bridge-line--done': index < currentStep }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.checkout-stepper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
align-items: flex-start;
|
||||
direction: rtl;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
padding: 22px 28px 20px;
|
||||
margin-bottom: 24px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.checkout-stepper__item {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 88px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.checkout-stepper__bridge {
|
||||
flex: 1 1 0;
|
||||
min-width: 20px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.checkout-stepper__bridge-line {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: #e2e8f0;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.checkout-stepper__bridge-line--done {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.checkout-stepper__dot {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #e2e8f0;
|
||||
background: var(--color-surface);
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checkout-stepper__item--done .checkout-stepper__dot {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.checkout-stepper__item--active .checkout-stepper__dot {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.checkout-stepper__check {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.checkout-stepper__label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: #94a3b8;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.checkout-stepper__item--active .checkout-stepper__label {
|
||||
color: var(--color-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.checkout-stepper__item--done .checkout-stepper__label {
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,292 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useCartStore } from '@/stores/cart'
|
||||
import IconSearch from '@/components/icons/IconSearch.vue'
|
||||
import IconBell from '@/components/icons/IconBell.vue'
|
||||
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const cartStore = useCartStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const showUserMenu = ref(false)
|
||||
|
||||
const initials = computed(() => {
|
||||
const first = authStore.user?.firstName?.[0] ?? ''
|
||||
const last = authStore.user?.lastName?.[0] ?? ''
|
||||
return `${first}${last}` || '؟'
|
||||
})
|
||||
|
||||
const companyLabel = computed(() => cartStore.activeCart?.companyName ?? '')
|
||||
|
||||
const searchPlaceholder = computed(() => {
|
||||
if (route.path.startsWith('/orders')) return 'جستجو در سفارشها...'
|
||||
if (route.path.startsWith('/companies')) return 'جستجو در محصولات...'
|
||||
return 'جستجو در سفارشها، شرکتها...'
|
||||
})
|
||||
|
||||
function handleSearch() {
|
||||
const q = searchQuery.value.trim()
|
||||
if (!q) return
|
||||
if (route.path.startsWith('/orders')) {
|
||||
router.replace({ path: '/orders', query: { ...route.query, q } })
|
||||
} else if (route.path.includes('/catalogs') && !route.path.includes('/products')) {
|
||||
router.replace({ query: { ...route.query, search: q } })
|
||||
}
|
||||
}
|
||||
|
||||
function toggleUserMenu() {
|
||||
showUserMenu.value = !showUserMenu.value
|
||||
}
|
||||
|
||||
function goProfile() {
|
||||
showUserMenu.value = false
|
||||
router.push('/profile')
|
||||
}
|
||||
|
||||
function logout() {
|
||||
showUserMenu.value = false
|
||||
authStore.logout()
|
||||
router.replace('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="desktop-header">
|
||||
<div class="desktop-header__search-wrap">
|
||||
<IconSearch :size="18" class="desktop-header__search-icon" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
class="desktop-header__search"
|
||||
:placeholder="searchPlaceholder"
|
||||
@keydown.enter="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="desktop-header__actions">
|
||||
<span v-if="companyLabel" class="desktop-header__company">{{ companyLabel }}</span>
|
||||
|
||||
<button type="button" class="desktop-header__icon-btn" aria-label="اعلانها">
|
||||
<IconBell :size="20" />
|
||||
<span class="desktop-header__notif-dot" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<div class="desktop-header__user-wrap">
|
||||
<button
|
||||
type="button"
|
||||
class="desktop-header__user"
|
||||
:aria-expanded="showUserMenu"
|
||||
@click="toggleUserMenu"
|
||||
>
|
||||
<span class="desktop-header__avatar">{{ initials }}</span>
|
||||
<span class="desktop-header__user-name">{{ authStore.fullName }}</span>
|
||||
<IconChevronLeft :size="16" class="desktop-header__chevron" />
|
||||
</button>
|
||||
|
||||
<div v-if="showUserMenu" class="desktop-header__menu">
|
||||
<button type="button" @click="goProfile">پروفایل</button>
|
||||
<button type="button" class="desktop-header__menu-logout" @click="logout">خروج</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.desktop-header {
|
||||
display: none;
|
||||
flex-shrink: 0;
|
||||
height: var(--header-height);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 0 28px;
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.desktop-header {
|
||||
display: flex;
|
||||
padding: 0 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.desktop-header {
|
||||
padding: 0 28px;
|
||||
gap: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.desktop-header__search-wrap {
|
||||
flex: 1;
|
||||
max-width: 420px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.desktop-header__search-icon {
|
||||
position: absolute;
|
||||
inset-inline-start: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--color-text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.desktop-header__search {
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
padding-inline: 42px 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: #f9fafb;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.desktop-header__search:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.desktop-header__search::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.desktop-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.desktop-header__company {
|
||||
display: none;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
padding-inline-end: 8px;
|
||||
border-inline-end: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.desktop-header__company {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.desktop-header__icon-btn {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.desktop-header__notif-dot {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
inset-inline-end: 9px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-danger);
|
||||
border: 2px solid var(--color-surface);
|
||||
}
|
||||
|
||||
.desktop-header__user-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.desktop-header__user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 12px 6px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.desktop-header__avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.desktop-header__user-name {
|
||||
display: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.desktop-header__user-name {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.desktop-header__chevron {
|
||||
color: var(--color-text-muted);
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.desktop-header__menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
inset-inline-end: 0;
|
||||
min-width: 160px;
|
||||
padding: 6px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.desktop-header__menu button {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: start;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.desktop-header__menu button:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.desktop-header__menu-logout {
|
||||
color: var(--color-danger) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,296 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useCartStore } from '@/stores/cart'
|
||||
import { useOrderStore } from '@/stores/order'
|
||||
import {
|
||||
DESKTOP_NAV_ITEMS,
|
||||
isNavItemActive,
|
||||
resolveNavPath,
|
||||
} from '@/utils/navigation'
|
||||
import IconHeadset from '@/components/icons/IconHeadset.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const cartStore = useCartStore()
|
||||
const orderStore = useOrderStore()
|
||||
|
||||
const cartCount = computed(() => cartStore.itemCount)
|
||||
const draftCheckCount = computed(() => orderStore.draftChecks.length)
|
||||
const activeCompanyId = computed(() => cartStore.activeCart?.companyId ?? null)
|
||||
|
||||
function navigate(item: (typeof DESKTOP_NAV_ITEMS)[number]) {
|
||||
router.push(resolveNavPath(item, activeCompanyId.value))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="desktop-sidebar" aria-label="ناوبری اصلی">
|
||||
<div class="desktop-sidebar__brand">
|
||||
<span class="desktop-sidebar__logo-mark" aria-hidden="true">پ</span>
|
||||
<strong class="desktop-sidebar__title">پارس بافت</strong>
|
||||
</div>
|
||||
|
||||
<nav class="desktop-sidebar__nav">
|
||||
<button
|
||||
v-for="item in DESKTOP_NAV_ITEMS"
|
||||
:key="item.name"
|
||||
type="button"
|
||||
class="desktop-sidebar__item"
|
||||
:class="{ active: isNavItemActive(item.name, item.path, route.path) }"
|
||||
:aria-current="isNavItemActive(item.name, item.path, route.path) ? 'page' : undefined"
|
||||
:aria-label="item.label"
|
||||
:title="item.label"
|
||||
@click="navigate(item)"
|
||||
>
|
||||
<span class="desktop-sidebar__icon">
|
||||
<component :is="item.icon" :size="20" />
|
||||
</span>
|
||||
<span class="desktop-sidebar__label">{{ item.label }}</span>
|
||||
<span v-if="item.name === 'cart' && cartCount > 0" class="desktop-sidebar__badge">
|
||||
{{ cartCount }}
|
||||
</span>
|
||||
<span v-if="item.name === 'checks' && draftCheckCount > 0" class="desktop-sidebar__badge">
|
||||
{{ draftCheckCount }}
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="desktop-sidebar__support">
|
||||
<span class="desktop-sidebar__support-icon" aria-hidden="true">
|
||||
<IconHeadset :size="22" />
|
||||
</span>
|
||||
<strong class="desktop-sidebar__support-title">پشتیبانی و راهنما</strong>
|
||||
<p class="desktop-sidebar__support-text">
|
||||
در صورت نیاز به راهنمایی یا گزارش مشکل با تیم پشتیبانی تماس بگیرید.
|
||||
</p>
|
||||
<a href="tel:02112345678" class="desktop-sidebar__support-btn">تماس با پشتیبانی</a>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.desktop-sidebar {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
width: var(--sidebar-width-compact);
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
background: var(--color-surface);
|
||||
border-inline-start: 1px solid var(--color-border);
|
||||
padding: 16px 8px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.desktop-sidebar {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.desktop-sidebar {
|
||||
width: var(--sidebar-width);
|
||||
padding: 24px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.desktop-sidebar__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 0 4px 20px;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.desktop-sidebar__brand {
|
||||
justify-content: flex-start;
|
||||
padding: 0 12px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.desktop-sidebar__logo-mark {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.desktop-sidebar__title {
|
||||
display: none;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.desktop-sidebar__title {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.desktop-sidebar__nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.desktop-sidebar__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: start;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.desktop-sidebar__item {
|
||||
justify-content: flex-start;
|
||||
padding: 11px 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.desktop-sidebar__item:hover {
|
||||
background: #f8fafc;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.desktop-sidebar__item.active {
|
||||
background: var(--color-primary-soft);
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.desktop-sidebar__item.active .desktop-sidebar__icon {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.desktop-sidebar__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.desktop-sidebar__label {
|
||||
display: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.desktop-sidebar__label {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.desktop-sidebar__badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
inset-inline-end: 6px;
|
||||
background: var(--color-danger);
|
||||
color: #fff;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.desktop-sidebar__badge {
|
||||
position: static;
|
||||
margin-inline-start: auto;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.desktop-sidebar__support {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
padding: 16px;
|
||||
background: var(--color-primary-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.desktop-sidebar__support {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.desktop-sidebar__support-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.desktop-sidebar__support-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.desktop-sidebar__support-text {
|
||||
margin: 0;
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.desktop-sidebar__support-btn {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fff;
|
||||
color: var(--color-primary);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.desktop-sidebar__support-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import IconChevronBack from '@/components/icons/IconChevronBack.vue'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
subtitle?: string
|
||||
showBack?: boolean
|
||||
centered?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
back: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="centered">
|
||||
<header class="page-nav">
|
||||
<button
|
||||
v-if="showBack"
|
||||
type="button"
|
||||
class="nav-btn"
|
||||
aria-label="بازگشت"
|
||||
@click="$emit('back')"
|
||||
>
|
||||
<IconChevronBack :size="20" />
|
||||
</button>
|
||||
<h1 class="page-nav__title">{{ title }}</h1>
|
||||
<button type="button" class="nav-btn nav-btn--ghost" aria-hidden="true" tabindex="-1">
|
||||
<IconChevronBack :size="18" class="nav-btn__flip" />
|
||||
</button>
|
||||
</header>
|
||||
<p v-if="subtitle" class="page-nav__subtitle">{{ subtitle }}</p>
|
||||
</template>
|
||||
|
||||
<header v-else class="page-header">
|
||||
<button
|
||||
v-if="showBack"
|
||||
type="button"
|
||||
class="back-btn"
|
||||
aria-label="بازگشت"
|
||||
@click="$emit('back')"
|
||||
>
|
||||
<IconChevronBack :size="20" />
|
||||
</button>
|
||||
<div class="header-text">
|
||||
<h1 class="page-title">{{ title }}</h1>
|
||||
<p v-if="subtitle" class="page-subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-nav {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr 40px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.page-nav__title {
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.page-nav__subtitle {
|
||||
margin: 0 0 16px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nav-btn--ghost {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nav-btn__flip {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text);
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
background 0.15s;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
border-color: #cbd5e1;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.header-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 4px 0 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import type { StatusHeroConfig } from '@/utils/orderTracking'
|
||||
|
||||
defineProps<{
|
||||
config: StatusHeroConfig
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="status-hero" :class="`status-hero--${config.variant}`">
|
||||
<div class="status-hero__icon" aria-hidden="true">
|
||||
<span v-if="config.variant === 'waiting'">⏳</span>
|
||||
<span v-else-if="config.variant === 'success'">✓</span>
|
||||
<span v-else>ℹ</span>
|
||||
</div>
|
||||
<h2 class="status-hero__title">{{ config.title }}</h2>
|
||||
<p v-if="config.description" class="status-hero__desc">{{ config.description }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 8px 12px 20px;
|
||||
}
|
||||
|
||||
.status-hero__icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.status-hero--waiting .status-hero__icon {
|
||||
background: #eef4ff;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.status-hero--success .status-hero__icon {
|
||||
background: var(--color-success);
|
||||
color: #fff;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-hero--info .status-hero__icon {
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.status-hero__title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.status-hero--success .status-hero__title {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.status-hero__desc {
|
||||
margin: 0;
|
||||
max-width: 320px;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Order } from '@/types'
|
||||
import { formatCurrency, formatNumber, toPersianDigits } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
order: Order
|
||||
}>()
|
||||
|
||||
const checksTotal = computed(() =>
|
||||
props.order.checks?.reduce((sum, check) => sum + check.amount, 0) ?? 0,
|
||||
)
|
||||
|
||||
function settlementLabel(type: 'cash' | 'check') {
|
||||
return type === 'cash' ? 'تسویه نقدی' : 'تسویه چکی'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tracking-summary">
|
||||
<div class="tracking-summary__row">
|
||||
<span>مبلغ فاکتور (تقریبی)</span>
|
||||
<strong>{{ order.finalAmount ? formatNumber(order.finalAmount) : '—' }}</strong>
|
||||
</div>
|
||||
<div class="tracking-summary__row">
|
||||
<span>نوع تسویه</span>
|
||||
<strong>{{ settlementLabel(order.settlementType) }}</strong>
|
||||
</div>
|
||||
|
||||
<template v-if="order.settlementType === 'check' && order.checks?.length">
|
||||
<div class="tracking-summary__row">
|
||||
<span>تعداد چکها</span>
|
||||
<strong>{{ toPersianDigits(String(order.checks.length)) }} فقره</strong>
|
||||
</div>
|
||||
<div class="tracking-summary__row">
|
||||
<span>مجموع مبلغ چکها</span>
|
||||
<strong>{{ formatCurrency(checksTotal) }}</strong>
|
||||
</div>
|
||||
<div v-if="order.checkRassDays != null" class="tracking-summary__row">
|
||||
<span>رأس چک (تقریبی)</span>
|
||||
<strong>{{ toPersianDigits(String(order.checkRassDays)) }} روز</strong>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="order.settlementType === 'cash' && order.cashReceipt">
|
||||
<div class="tracking-summary__row">
|
||||
<span>مبلغ واریزی</span>
|
||||
<strong>{{ formatCurrency(order.cashReceipt.amount) }}</strong>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tracking-summary {
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.tracking-summary__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
font-size: 0.875rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.tracking-summary__row:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.tracking-summary__row span {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.tracking-summary__row strong {
|
||||
color: var(--color-text);
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Order, OrderStatus } from '@/types'
|
||||
import { ORDER_TRACKING_STEPS } from '@/utils/constants'
|
||||
import { getStepTimestamp, getTrackingStepIndex } from '@/utils/orderTracking'
|
||||
|
||||
const props = defineProps<{
|
||||
order: Order
|
||||
}>()
|
||||
|
||||
const currentIndex = computed(() => getTrackingStepIndex(props.order.status))
|
||||
|
||||
function stepState(index: number): 'done' | 'current' | 'pending' {
|
||||
if (index < currentIndex.value) return 'done'
|
||||
if (index === currentIndex.value) return 'current'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
function formatStepTime(date: string | null) {
|
||||
if (!date) return null
|
||||
const d = new Date(date)
|
||||
const datePart = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(d)
|
||||
const timePart = new Intl.DateTimeFormat('fa-IR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(d)
|
||||
return `${datePart} - ${timePart}`
|
||||
}
|
||||
|
||||
function timestampFor(step: OrderStatus) {
|
||||
return formatStepTime(getStepTimestamp(props.order, step))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tracking-timeline">
|
||||
<div
|
||||
v-for="(step, index) in ORDER_TRACKING_STEPS"
|
||||
:key="step.status"
|
||||
class="tracking-timeline__item"
|
||||
:class="`tracking-timeline__item--${stepState(index)}`"
|
||||
>
|
||||
<div class="tracking-timeline__marker-wrap">
|
||||
<div class="tracking-timeline__line" />
|
||||
<div class="tracking-timeline__marker">
|
||||
<span v-if="stepState(index) === 'done'" class="tracking-timeline__check">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tracking-timeline__content">
|
||||
<strong class="tracking-timeline__label">{{ step.label }}</strong>
|
||||
<span v-if="timestampFor(step.status)" class="tracking-timeline__time">
|
||||
{{ timestampFor(step.status) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tracking-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.tracking-timeline__item {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr;
|
||||
gap: 12px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.tracking-timeline__item--current {
|
||||
margin: 4px 0;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
background: #eef4ff;
|
||||
}
|
||||
|
||||
.tracking-timeline__marker-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tracking-timeline__line {
|
||||
position: absolute;
|
||||
top: 22px;
|
||||
bottom: -10px;
|
||||
width: 2px;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.tracking-timeline__item:last-child .tracking-timeline__line {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tracking-timeline__item--done .tracking-timeline__line {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.tracking-timeline__marker {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: #e2e8f0;
|
||||
border: 2px solid #e2e8f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tracking-timeline__item--done .tracking-timeline__marker {
|
||||
background: var(--color-success);
|
||||
border-color: var(--color-success);
|
||||
}
|
||||
|
||||
.tracking-timeline__item--current .tracking-timeline__marker {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 4px rgba(29, 53, 87, 0.12);
|
||||
}
|
||||
|
||||
.tracking-timeline__item--pending .tracking-timeline__marker {
|
||||
background: #fff;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.tracking-timeline__check {
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tracking-timeline__content {
|
||||
min-width: 0;
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
.tracking-timeline__label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.tracking-timeline__item--done .tracking-timeline__label,
|
||||
.tracking-timeline__item--current .tracking-timeline__label {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.tracking-timeline__time {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
variant?: 'warning' | 'success' | 'info' | 'purple' | 'danger' | 'neutral'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="badge" :class="`badge-${variant ?? 'neutral'}`">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: var(--color-warning-light);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: var(--color-success-light);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background: var(--color-info-light);
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.badge-purple {
|
||||
background: var(--color-purple-light);
|
||||
color: var(--color-purple);
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background: var(--color-danger-light);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.badge-neutral {
|
||||
background: #f1f5f9;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
block?: boolean
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
type?: 'button' | 'submit'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:type="type ?? 'button'"
|
||||
class="btn"
|
||||
:class="[`btn-${variant ?? 'primary'}`, `btn-${size ?? 'md'}`, { 'btn-block': block }]"
|
||||
:disabled="disabled || loading"
|
||||
>
|
||||
<span v-if="loading" class="btn-spinner" />
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
opacity 0.15s;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
min-height: 36px;
|
||||
padding: 0 14px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.btn-md {
|
||||
min-height: var(--btn-height);
|
||||
padding: 0 24px;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
min-height: 52px;
|
||||
padding: 0 28px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:not(:disabled):hover {
|
||||
background: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
border: 1.5px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--color-danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.btn-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
modelValue?: string | number
|
||||
label?: string
|
||||
type?: string
|
||||
placeholder?: string
|
||||
hint?: string
|
||||
error?: string
|
||||
disabled?: boolean
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="form-group">
|
||||
<label v-if="label" class="form-label">{{ label }}</label>
|
||||
<input
|
||||
class="input"
|
||||
:class="{ 'input-error': error }"
|
||||
:type="type ?? 'text'"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<p v-if="error" class="input-error-text">{{ error }}</p>
|
||||
<p v-else-if="hint" class="form-hint">{{ hint }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.input {
|
||||
width: 100%;
|
||||
height: var(--btn-height);
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.9375rem;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.input-error {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.input-error-text {
|
||||
color: var(--color-danger);
|
||||
font-size: 0.8125rem;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { formatMeterage } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
current: number
|
||||
max: number
|
||||
}>()
|
||||
|
||||
const remaining = computed(() => Math.max(0, props.max - props.current))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="capacity-summary">
|
||||
<div class="capacity-summary__row">
|
||||
<span class="capacity-summary__label">جمع متراژ</span>
|
||||
<span class="capacity-summary__value">{{ formatMeterage(current) }}</span>
|
||||
</div>
|
||||
<div class="capacity-summary__row">
|
||||
<span class="capacity-summary__label">حداکثر مجاز</span>
|
||||
<span class="capacity-summary__value">{{ formatMeterage(max) }}</span>
|
||||
</div>
|
||||
<div class="capacity-summary__row">
|
||||
<span class="capacity-summary__label">باقیمانده</span>
|
||||
<span class="capacity-summary__value capacity-summary__value--accent">
|
||||
{{ formatMeterage(remaining) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.capacity-summary {
|
||||
background: var(--color-summary-bg);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.capacity-summary__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.capacity-summary__row:not(:last-child) {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.capacity-summary__label {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.capacity-summary__value {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.capacity-summary__value--accent {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { formatMeterage } from '@/utils/format'
|
||||
import IconCart from '@/components/icons/IconCart.vue'
|
||||
|
||||
defineProps<{
|
||||
totalMeterage: number
|
||||
itemCount?: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cart-meterage-footer">
|
||||
<div class="cart-meterage-footer__info">
|
||||
<span class="cart-meterage-footer__label">کل متراژ</span>
|
||||
<span class="cart-meterage-footer__value">{{ formatMeterage(totalMeterage) }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="cart-meterage-footer__btn"
|
||||
aria-label="مشاهده سبد"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<IconCart :size="22" />
|
||||
<span v-if="itemCount" class="cart-meterage-footer__badge">{{ itemCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cart-meterage-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.cart-meterage-footer__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.cart-meterage-footer__label {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.cart-meterage-footer__value {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cart-meterage-footer__btn {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.cart-meterage-footer__btn:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.cart-meterage-footer__badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: -4px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-danger);
|
||||
color: #fff;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { extractNumericString, formatNumber } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
hint?: string
|
||||
error?: string
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const displayValue = ref('')
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
const numeric = extractNumericString(value ?? '')
|
||||
displayValue.value = numeric ? formatNumber(Number(numeric)) : ''
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function onInput(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const numeric = extractNumericString(input.value)
|
||||
displayValue.value = numeric ? formatNumber(Number(numeric)) : ''
|
||||
emit('update:modelValue', numeric)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="form-group">
|
||||
<label v-if="label" class="form-label">{{ label }}</label>
|
||||
<input
|
||||
class="input"
|
||||
:class="{ 'input-error': error }"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
dir="ltr"
|
||||
:value="displayValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
@input="onInput"
|
||||
/>
|
||||
<p v-if="error" class="input-error-text">{{ error }}</p>
|
||||
<p v-else-if="hint" class="form-hint">{{ hint }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.input {
|
||||
width: 100%;
|
||||
height: var(--btn-height);
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.9375rem;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
transition: border-color 0.15s;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.input-error {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.input-error-text {
|
||||
color: var(--color-danger);
|
||||
font-size: 0.8125rem;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import IconClipboardEmpty from '@/components/icons/IconClipboardEmpty.vue'
|
||||
|
||||
defineProps<{
|
||||
message?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state__icon">
|
||||
<slot name="icon">
|
||||
<IconClipboardEmpty :size="72" />
|
||||
</slot>
|
||||
</div>
|
||||
<p class="empty-state__message">
|
||||
<slot name="message">{{ message }}</slot>
|
||||
</p>
|
||||
<div v-if="$slots.default" class="empty-state__action">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
padding: 48px 24px;
|
||||
min-height: 320px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state__icon {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-state__message {
|
||||
margin: 0 0 24px;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.empty-state__action {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
tabs: { id: string; label: string }[]
|
||||
equal?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="filter-tabs" :class="{ 'filter-tabs--equal': equal }" role="tablist">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
type="button"
|
||||
role="tab"
|
||||
class="filter-tab"
|
||||
:class="{ active: modelValue === tab.id }"
|
||||
:aria-selected="modelValue === tab.id"
|
||||
@click="$emit('update:modelValue', tab.id)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.filter-tabs--equal {
|
||||
gap: 6px;
|
||||
overflow: visible;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.filter-tab {
|
||||
flex-shrink: 0;
|
||||
height: 36px;
|
||||
padding: 0 18px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: #f5f5f5;
|
||||
color: #64748b;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.filter-tabs--equal .filter-tab {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 38px;
|
||||
padding: 0 6px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.filter-tab.active {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter-tabs--equal .filter-tab.active {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter-tab:not(.active):hover {
|
||||
background: #eceff1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
message?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="message" class="form-error-banner" role="alert">
|
||||
{{ message }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-error-banner {
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: var(--color-danger);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { formatMeterage } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
current: number
|
||||
max: number
|
||||
}>()
|
||||
|
||||
const remaining = computed(() => Math.max(0, props.max - props.current))
|
||||
const percentage = computed(() => Math.min(100, (props.current / props.max) * 100))
|
||||
const isNearFull = computed(() => percentage.value >= 80)
|
||||
const isFull = computed(() => props.current >= props.max)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="meterage-bar" :class="{ near: isNearFull, full: isFull }">
|
||||
<div class="meterage-info">
|
||||
<span
|
||||
>متراژ سبد: <strong>{{ formatMeterage(current) }}</strong></span
|
||||
>
|
||||
<span
|
||||
>باقیمانده: <strong>{{ formatMeterage(remaining) }}</strong></span
|
||||
>
|
||||
</div>
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" :style="{ width: `${percentage}%` }" />
|
||||
</div>
|
||||
<p class="meterage-max text-sm text-muted">حداکثر مجاز: {{ formatMeterage(max) }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.meterage-bar {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.meterage-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 8px;
|
||||
background: #e2e8f0;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--color-primary);
|
||||
border-radius: 999px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.meterage-bar.near .progress-fill {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
.meterage-bar.full .progress-fill {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
.meterage-max {
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,328 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
JALALI_MONTH_NAMES,
|
||||
JALALI_WEEKDAY_NAMES,
|
||||
buildCalendarDays,
|
||||
formatJalaliLabel,
|
||||
isoToJalali,
|
||||
jalaliToISO,
|
||||
todayJalali,
|
||||
} from '@/utils/jalali'
|
||||
import { toPersianDigits } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
hint?: string
|
||||
error?: string
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const isOpen = ref(false)
|
||||
const viewYear = ref(todayJalali().jy)
|
||||
const viewMonth = ref(todayJalali().jm)
|
||||
|
||||
const selectedJalali = computed(() => isoToJalali(props.modelValue ?? ''))
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (!selectedJalali.value) return ''
|
||||
const { jy, jm, jd } = selectedJalali.value
|
||||
return formatJalaliLabel(jy, jm, jd)
|
||||
})
|
||||
|
||||
const calendarDays = computed(() => buildCalendarDays(viewYear.value, viewMonth.value))
|
||||
|
||||
const monthLabel = computed(
|
||||
() => `${JALALI_MONTH_NAMES[viewMonth.value - 1]} ${toPersianDigits(String(viewYear.value))}`,
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
const jalali = isoToJalali(value ?? '')
|
||||
if (jalali) {
|
||||
viewYear.value = jalali.jy
|
||||
viewMonth.value = jalali.jm
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function toggleCalendar() {
|
||||
if (props.disabled) return
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
function closeCalendar() {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function selectDay(day: number) {
|
||||
emit('update:modelValue', jalaliToISO(viewYear.value, viewMonth.value, day))
|
||||
closeCalendar()
|
||||
}
|
||||
|
||||
function changeMonth(delta: number) {
|
||||
let month = viewMonth.value + delta
|
||||
let year = viewYear.value
|
||||
|
||||
if (month > 12) {
|
||||
month = 1
|
||||
year += 1
|
||||
} else if (month < 1) {
|
||||
month = 12
|
||||
year -= 1
|
||||
}
|
||||
|
||||
viewMonth.value = month
|
||||
viewYear.value = year
|
||||
}
|
||||
|
||||
function selectToday() {
|
||||
const today = todayJalali()
|
||||
viewYear.value = today.jy
|
||||
viewMonth.value = today.jm
|
||||
emit('update:modelValue', jalaliToISO(today.jy, today.jm, today.jd))
|
||||
closeCalendar()
|
||||
}
|
||||
|
||||
function isSelected(day: number) {
|
||||
if (!selectedJalali.value) return false
|
||||
const { jy, jm, jd } = selectedJalali.value
|
||||
return jy === viewYear.value && jm === viewMonth.value && jd === day
|
||||
}
|
||||
|
||||
function isToday(day: number) {
|
||||
const today = todayJalali()
|
||||
return today.jy === viewYear.value && today.jm === viewMonth.value && today.jd === day
|
||||
}
|
||||
|
||||
function onDocumentClick(event: MouseEvent) {
|
||||
if (!rootRef.value?.contains(event.target as Node)) {
|
||||
closeCalendar()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootRef" class="form-group persian-date-input">
|
||||
<label v-if="label" class="form-label">{{ label }}</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="input persian-date-input__trigger"
|
||||
:class="{ 'input-error': error, 'is-open': isOpen }"
|
||||
:disabled="disabled"
|
||||
@click.stop="toggleCalendar"
|
||||
>
|
||||
<span :class="{ 'persian-date-input__placeholder': !displayValue }">
|
||||
{{ displayValue || placeholder || 'انتخاب تاریخ' }}
|
||||
</span>
|
||||
<span class="persian-date-input__icon" aria-hidden="true">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M8 3v4M16 3v4M3 10h18" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div v-if="isOpen" class="persian-date-input__panel" @click.stop>
|
||||
<div class="persian-date-input__header">
|
||||
<button type="button" class="persian-date-input__nav" @click="changeMonth(-1)">‹</button>
|
||||
<strong class="persian-date-input__month">{{ monthLabel }}</strong>
|
||||
<button type="button" class="persian-date-input__nav" @click="changeMonth(1)">›</button>
|
||||
</div>
|
||||
|
||||
<div class="persian-date-input__weekdays">
|
||||
<span v-for="weekday in JALALI_WEEKDAY_NAMES" :key="weekday">{{ weekday }}</span>
|
||||
</div>
|
||||
|
||||
<div class="persian-date-input__grid">
|
||||
<template v-for="(day, index) in calendarDays" :key="index">
|
||||
<span v-if="day === null" class="persian-date-input__cell persian-date-input__cell--empty" />
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="persian-date-input__cell"
|
||||
:class="{
|
||||
'persian-date-input__cell--selected': isSelected(day),
|
||||
'persian-date-input__cell--today': isToday(day),
|
||||
}"
|
||||
@click="selectDay(day)"
|
||||
>
|
||||
{{ toPersianDigits(String(day)) }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button type="button" class="persian-date-input__today" @click="selectToday">امروز</button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="input-error-text">{{ error }}</p>
|
||||
<p v-else-if="hint" class="form-hint">{{ hint }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.persian-date-input {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
height: var(--btn-height);
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.9375rem;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.persian-date-input__trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.persian-date-input__trigger:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.persian-date-input__trigger.is-open,
|
||||
.persian-date-input__trigger:focus-visible {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.persian-date-input__trigger:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.persian-date-input__placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.persian-date-input__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.input-error {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.input-error-text {
|
||||
color: var(--color-danger);
|
||||
font-size: 0.8125rem;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.persian-date-input__panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 120;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.persian-date-input__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.persian-date-input__month {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.persian-date-input__nav {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fff;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.persian-date-input__weekdays,
|
||||
.persian-date-input__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.persian-date-input__weekdays {
|
||||
margin-bottom: 6px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.persian-date-input__cell {
|
||||
height: 34px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.persian-date-input__cell--empty {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.persian-date-input__cell--today {
|
||||
border: 1px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.persian-date-input__cell--selected {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.persian-date-input__today {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #f8fafc;
|
||||
color: var(--color-primary);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
modelValue?: string
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="search-field">
|
||||
<input
|
||||
class="search-field__input"
|
||||
type="search"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-field__input {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-family: inherit;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.5;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.search-field__input::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.search-field__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import BaseBadge from './BaseBadge.vue'
|
||||
import { ORDER_STATUS_LABELS, STATUS_BADGE_VARIANT } from '@/utils/constants'
|
||||
import type { OrderStatus } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
status: OrderStatus
|
||||
}>()
|
||||
|
||||
const label = computed(() => ORDER_STATUS_LABELS[props.status] ?? props.status)
|
||||
const variant = computed(() => STATUS_BADGE_VARIANT[props.status] ?? 'neutral')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBadge :variant="variant">{{ label }}</BaseBadge>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useCartStore } from '@/stores/cart'
|
||||
|
||||
export function useCartPage() {
|
||||
const router = useRouter()
|
||||
const cartStore = useCartStore()
|
||||
const showFullDialog = ref(false)
|
||||
|
||||
const items = computed(() => cartStore.activeCart?.items ?? [])
|
||||
const isEmpty = computed(() => items.value.length === 0)
|
||||
const companyName = computed(() => cartStore.activeCart?.companyName ?? '')
|
||||
|
||||
function removeItem(itemId: string) {
|
||||
cartStore.removeItem(itemId)
|
||||
}
|
||||
|
||||
function proceedToCheckout() {
|
||||
if (items.value.length === 0) return
|
||||
if (cartStore.isFull) {
|
||||
showFullDialog.value = true
|
||||
return
|
||||
}
|
||||
router.push('/checkout/settlement')
|
||||
}
|
||||
|
||||
function proceedAnyway() {
|
||||
showFullDialog.value = false
|
||||
router.push('/checkout/settlement')
|
||||
}
|
||||
|
||||
function createNewInvoice() {
|
||||
cartStore.createNewCartForSameCompany()
|
||||
showFullDialog.value = false
|
||||
const companyId = cartStore.activeCart?.companyId
|
||||
if (companyId) {
|
||||
router.push(`/companies/${companyId}/catalogs`)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
cartStore.ensureActiveCartForCartPage()
|
||||
})
|
||||
|
||||
return {
|
||||
cartStore,
|
||||
items,
|
||||
isEmpty,
|
||||
companyName,
|
||||
showFullDialog,
|
||||
removeItem,
|
||||
proceedToCheckout,
|
||||
proceedAnyway,
|
||||
createNewInvoice,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useOrderStore } from '@/stores/order'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { getCompanyById } from '@/data/mockData'
|
||||
import type { Order } from '@/types'
|
||||
import {
|
||||
DELIVERED_STATUSES,
|
||||
IN_PROGRESS_STATUSES,
|
||||
REVIEW_STATUSES,
|
||||
matchesDesktopOrderFilter,
|
||||
matchesMobileOrderTab,
|
||||
type DesktopOrderFilterId,
|
||||
} from '@/utils/orderStatusGroups'
|
||||
|
||||
const DESKTOP_PAGE_SIZE = 5
|
||||
|
||||
export function useOrderList() {
|
||||
const router = useRouter()
|
||||
const orderStore = useOrderStore()
|
||||
const authStore = useAuthStore()
|
||||
const currentPage = ref(1)
|
||||
|
||||
const tab = computed({
|
||||
get: () => (router.currentRoute.value.query.tab as string) ?? 'all',
|
||||
set: (v: string) => router.replace({ query: { ...router.currentRoute.value.query, tab: v } }),
|
||||
})
|
||||
|
||||
const desktopFilter = computed({
|
||||
get: () => (router.currentRoute.value.query.filter as DesktopOrderFilterId) ?? 'all',
|
||||
set: (v: DesktopOrderFilterId) =>
|
||||
router.replace({
|
||||
query: { ...router.currentRoute.value.query, filter: v === 'all' ? undefined : v },
|
||||
}),
|
||||
})
|
||||
|
||||
const searchQuery = computed(() => (router.currentRoute.value.query.q as string) ?? '')
|
||||
|
||||
const orders = computed(() => {
|
||||
if (!authStore.user) return []
|
||||
return orderStore.userOrders(authStore.user.id)
|
||||
})
|
||||
|
||||
const stats = computed(() => ({
|
||||
total: orders.value.length,
|
||||
review: orders.value.filter((o) => REVIEW_STATUSES.includes(o.status)).length,
|
||||
inProgress: orders.value.filter((o) => IN_PROGRESS_STATUSES.includes(o.status)).length,
|
||||
done: orders.value.filter((o) => DELIVERED_STATUSES.includes(o.status)).length,
|
||||
}))
|
||||
|
||||
function matchesSearch(order: Order, q: string): boolean {
|
||||
if (!q.trim()) return true
|
||||
const needle = q.trim().toLowerCase()
|
||||
return (
|
||||
order.orderNumber.toLowerCase().includes(needle) ||
|
||||
order.companyName.toLowerCase().includes(needle)
|
||||
)
|
||||
}
|
||||
|
||||
const filteredOrdersMobile = computed(() =>
|
||||
orders.value
|
||||
.filter((o) => matchesSearch(o, searchQuery.value))
|
||||
.filter((o) => matchesMobileOrderTab(o.status, tab.value)),
|
||||
)
|
||||
|
||||
const filteredOrdersDesktop = computed(() =>
|
||||
orders.value
|
||||
.filter((o) => matchesSearch(o, searchQuery.value))
|
||||
.filter((o) => matchesDesktopOrderFilter(o.status, desktopFilter.value)),
|
||||
)
|
||||
|
||||
const paginatedOrders = computed(() => {
|
||||
const start = (currentPage.value - 1) * DESKTOP_PAGE_SIZE
|
||||
return filteredOrdersDesktop.value.slice(start, start + DESKTOP_PAGE_SIZE)
|
||||
})
|
||||
|
||||
const totalPages = computed(() =>
|
||||
Math.max(1, Math.ceil(filteredOrdersDesktop.value.length / DESKTOP_PAGE_SIZE)),
|
||||
)
|
||||
|
||||
const paginationRange = computed(() => {
|
||||
if (filteredOrdersDesktop.value.length === 0) return { start: 0, end: 0 }
|
||||
const start = (currentPage.value - 1) * DESKTOP_PAGE_SIZE + 1
|
||||
const end = Math.min(currentPage.value * DESKTOP_PAGE_SIZE, filteredOrdersDesktop.value.length)
|
||||
return { start, end }
|
||||
})
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const total = totalPages.value
|
||||
const current = currentPage.value
|
||||
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1)
|
||||
|
||||
const pages: (number | 'ellipsis')[] = [1]
|
||||
if (current > 3) pages.push('ellipsis')
|
||||
|
||||
const rangeStart = Math.max(2, current - 1)
|
||||
const rangeEnd = Math.min(total - 1, current + 1)
|
||||
for (let p = rangeStart; p <= rangeEnd; p += 1) pages.push(p)
|
||||
|
||||
if (current < total - 2) pages.push('ellipsis')
|
||||
if (total > 1) pages.push(total)
|
||||
|
||||
return pages
|
||||
})
|
||||
|
||||
watch([filteredOrdersDesktop, desktopFilter, searchQuery], () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
|
||||
function companySlug(order: Order) {
|
||||
return getCompanyById(order.companyId)?.slug ?? 'par'
|
||||
}
|
||||
|
||||
function iconVariant(order: Order) {
|
||||
if (DELIVERED_STATUSES.includes(order.status)) return 'success'
|
||||
return companySlug(order) === 'fam-baft' ? 'fam-baft' : 'par'
|
||||
}
|
||||
|
||||
function goToOrder(id: string) {
|
||||
router.push(`/orders/${id}`)
|
||||
}
|
||||
|
||||
function setPage(page: number) {
|
||||
if (page < 1 || page > totalPages.value) return
|
||||
currentPage.value = page
|
||||
}
|
||||
|
||||
function setDesktopFilter(filter: DesktopOrderFilterId) {
|
||||
desktopFilter.value = desktopFilter.value === filter && filter !== 'all' ? 'all' : filter
|
||||
}
|
||||
|
||||
function viewStatFilter(filter: DesktopOrderFilterId) {
|
||||
desktopFilter.value = filter
|
||||
}
|
||||
|
||||
return {
|
||||
tab,
|
||||
desktopFilter,
|
||||
searchQuery,
|
||||
stats,
|
||||
filteredOrdersMobile,
|
||||
filteredOrdersDesktop,
|
||||
paginatedOrders,
|
||||
totalPages,
|
||||
paginationRange,
|
||||
visiblePages,
|
||||
currentPage,
|
||||
companySlug,
|
||||
iconVariant,
|
||||
goToOrder,
|
||||
setPage,
|
||||
setDesktopFilter,
|
||||
viewStatFilter,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useCatalogStore } from '@/stores/catalog'
|
||||
import { useCartStore } from '@/stores/cart'
|
||||
|
||||
export function useProductOrdering() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const catalogStore = useCatalogStore()
|
||||
const cartStore = useCartStore()
|
||||
|
||||
const companyId = computed(() => route.params.companyId as string)
|
||||
const catalogId = computed(() => route.params.catalogId as string)
|
||||
const catalog = computed(() => catalogStore.getCatalog(catalogId.value))
|
||||
const company = computed(() => catalogStore.getCompany(companyId.value))
|
||||
|
||||
const colorFilter = ref('all')
|
||||
const colors = computed(() => ['all', ...catalogStore.getUniqueColors(catalogId.value)])
|
||||
const colorLabels: Record<string, string> = { all: 'همه' }
|
||||
|
||||
const products = computed(() =>
|
||||
catalogStore.fetchProducts(
|
||||
catalogId.value,
|
||||
colorFilter.value === 'all' ? undefined : colorFilter.value,
|
||||
),
|
||||
)
|
||||
|
||||
const meterages = ref<Record<string, number>>({})
|
||||
const errors = ref<Record<string, string>>({})
|
||||
const toast = ref('')
|
||||
|
||||
function getMeterage(productId: string) {
|
||||
return meterages.value[productId] ?? 0
|
||||
}
|
||||
|
||||
function adjustMeterage(productId: string, delta: number, step: number) {
|
||||
const current = getMeterage(productId)
|
||||
meterages.value[productId] = Math.max(0, current + delta * step)
|
||||
}
|
||||
|
||||
function addProduct(productId: string) {
|
||||
const product = catalogStore.getProduct(productId)
|
||||
if (!product || !catalog.value) return
|
||||
|
||||
const meterage = getMeterage(productId)
|
||||
if (meterage <= 0) {
|
||||
errors.value[productId] = 'متراژ را وارد کنید.'
|
||||
return
|
||||
}
|
||||
|
||||
const result = cartStore.addItem(product, catalog.value.code, meterage)
|
||||
if (result.success) {
|
||||
errors.value[productId] = ''
|
||||
toast.value = `${product.name} به سبد اضافه شد.`
|
||||
setTimeout(() => {
|
||||
toast.value = ''
|
||||
}, 2000)
|
||||
} else {
|
||||
errors.value[productId] = result.message ?? 'خطا'
|
||||
}
|
||||
}
|
||||
|
||||
function setMeterage(productId: string, value: number) {
|
||||
meterages.value[productId] = Math.max(0, value)
|
||||
}
|
||||
|
||||
function goToCart() {
|
||||
router.push('/cart')
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push(`/companies/${companyId.value}/catalogs`)
|
||||
}
|
||||
|
||||
return {
|
||||
companyId,
|
||||
catalogId,
|
||||
catalog,
|
||||
company,
|
||||
colorFilter,
|
||||
colors,
|
||||
colorLabels,
|
||||
products,
|
||||
meterages,
|
||||
errors,
|
||||
toast,
|
||||
cartStore,
|
||||
adjustMeterage,
|
||||
setMeterage,
|
||||
addProduct,
|
||||
goToCart,
|
||||
goBack,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import type { BankAccount, Catalog, Company, Product } from '@/types'
|
||||
|
||||
export const companies: Company[] = [
|
||||
{
|
||||
id: 'company-par',
|
||||
name: 'شرکت پَر',
|
||||
slug: 'par',
|
||||
description: 'تولیدکننده پارچههای باکیفیت',
|
||||
isActive: true,
|
||||
displayOrder: 1,
|
||||
maxMeteragePerInvoice: 5000,
|
||||
cashInvoiceValidityHours: 8,
|
||||
cashInvoiceValidUntilHour: 17,
|
||||
},
|
||||
{
|
||||
id: 'company-fam-baft',
|
||||
name: 'فامبافت',
|
||||
slug: 'fam-baft',
|
||||
description: 'عرضه پارچههای تخصصی',
|
||||
isActive: true,
|
||||
displayOrder: 2,
|
||||
maxMeteragePerInvoice: 5000,
|
||||
cashInvoiceValidityHours: 8,
|
||||
cashInvoiceValidUntilHour: 17,
|
||||
},
|
||||
]
|
||||
|
||||
export const bankAccounts: BankAccount[] = [
|
||||
{
|
||||
id: 'ba-par-1',
|
||||
companyId: 'company-par',
|
||||
bankName: 'بانک ملی',
|
||||
accountHolder: 'شرکت پَر',
|
||||
accountNumber: '0123456789',
|
||||
cardNumber: '6037-9912-3456-7890',
|
||||
iban: 'IR120170000000012345678901',
|
||||
isActive: true,
|
||||
displayOrder: 1,
|
||||
},
|
||||
{
|
||||
id: 'ba-par-2',
|
||||
companyId: 'company-par',
|
||||
bankName: 'بانک صادرات',
|
||||
accountHolder: 'شرکت پَر',
|
||||
accountNumber: '9876543210',
|
||||
cardNumber: '6037-6912-3456-7891',
|
||||
iban: 'IR620190000000098765432101',
|
||||
isActive: true,
|
||||
displayOrder: 2,
|
||||
},
|
||||
{
|
||||
id: 'ba-fam-1',
|
||||
companyId: 'company-fam-baft',
|
||||
bankName: 'بانک ملت',
|
||||
accountHolder: 'فامبافت',
|
||||
accountNumber: '1122334455',
|
||||
cardNumber: '6104-3378-9012-3456',
|
||||
iban: 'IR450120000000011223344556',
|
||||
isActive: true,
|
||||
displayOrder: 1,
|
||||
},
|
||||
]
|
||||
|
||||
export const catalogs: Catalog[] = [
|
||||
{
|
||||
id: 'cat-377',
|
||||
companyId: 'company-par',
|
||||
name: 'کالیته ۳۷۷',
|
||||
code: '377',
|
||||
displayOrder: 1,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 'cat-378',
|
||||
companyId: 'company-par',
|
||||
name: 'کالیته ۳۷۸',
|
||||
code: '378',
|
||||
displayOrder: 2,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 'cat-379',
|
||||
companyId: 'company-par',
|
||||
name: 'کالیته ۳۷۹',
|
||||
code: '379',
|
||||
displayOrder: 3,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 'cat-380',
|
||||
companyId: 'company-par',
|
||||
name: 'کالیته ۳۸۰',
|
||||
code: '380',
|
||||
displayOrder: 4,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 'cat-381',
|
||||
companyId: 'company-par',
|
||||
name: 'کالیته ۳۸۱',
|
||||
code: '381',
|
||||
displayOrder: 5,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 'cat-fam-401',
|
||||
companyId: 'company-fam-baft',
|
||||
name: 'کالیته ۴۰۱',
|
||||
code: '401',
|
||||
displayOrder: 1,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 'cat-fam-402',
|
||||
companyId: 'company-fam-baft',
|
||||
name: 'کالیته ۴۰۲',
|
||||
code: '402',
|
||||
displayOrder: 2,
|
||||
isActive: true,
|
||||
},
|
||||
]
|
||||
|
||||
export const products: Product[] = [
|
||||
{
|
||||
id: 'prod-377-blue',
|
||||
companyId: 'company-par',
|
||||
catalogId: 'cat-377',
|
||||
name: 'پارچه آبی',
|
||||
code: '377-BL',
|
||||
color: 'آبی',
|
||||
minMeterage: 30,
|
||||
orderMultiplier: 10,
|
||||
isActive: true,
|
||||
stock: 10000,
|
||||
},
|
||||
{
|
||||
id: 'prod-377-black',
|
||||
companyId: 'company-par',
|
||||
catalogId: 'cat-377',
|
||||
name: 'پارچه مشکی',
|
||||
code: '377-BK',
|
||||
color: 'مشکی',
|
||||
minMeterage: 30,
|
||||
orderMultiplier: 10,
|
||||
isActive: true,
|
||||
stock: 8000,
|
||||
},
|
||||
{
|
||||
id: 'prod-378-blue',
|
||||
companyId: 'company-par',
|
||||
catalogId: 'cat-378',
|
||||
name: 'آبی',
|
||||
code: '378-BL',
|
||||
color: 'آبی',
|
||||
minMeterage: 50,
|
||||
orderMultiplier: 10,
|
||||
isActive: true,
|
||||
stock: 5000,
|
||||
},
|
||||
{
|
||||
id: 'prod-378-black',
|
||||
companyId: 'company-par',
|
||||
catalogId: 'cat-378',
|
||||
name: 'مشکی',
|
||||
code: '378-BK',
|
||||
color: 'مشکی',
|
||||
minMeterage: 50,
|
||||
orderMultiplier: 10,
|
||||
isActive: true,
|
||||
stock: 5000,
|
||||
},
|
||||
{
|
||||
id: 'prod-378-blue-white',
|
||||
companyId: 'company-par',
|
||||
catalogId: 'cat-378',
|
||||
name: 'آبی پشت سفید',
|
||||
code: '378-BLW',
|
||||
color: 'آبی پشت سفید',
|
||||
minMeterage: 50,
|
||||
orderMultiplier: 10,
|
||||
isActive: true,
|
||||
stock: 3000,
|
||||
},
|
||||
{
|
||||
id: 'prod-378-black-white',
|
||||
companyId: 'company-par',
|
||||
catalogId: 'cat-378',
|
||||
name: 'مشکی پشت سفید',
|
||||
code: '378-BKW',
|
||||
color: 'مشکی پشت سفید',
|
||||
minMeterage: 50,
|
||||
orderMultiplier: 10,
|
||||
isActive: true,
|
||||
stock: 3000,
|
||||
},
|
||||
{
|
||||
id: 'prod-fam-401-1',
|
||||
companyId: 'company-fam-baft',
|
||||
catalogId: 'cat-fam-401',
|
||||
name: 'پارچه مخمل',
|
||||
code: '401-MV',
|
||||
color: 'سرمهای',
|
||||
minMeterage: 40,
|
||||
orderMultiplier: 10,
|
||||
isActive: true,
|
||||
stock: 6000,
|
||||
},
|
||||
{
|
||||
id: 'prod-fam-401-2',
|
||||
companyId: 'company-fam-baft',
|
||||
catalogId: 'cat-fam-401',
|
||||
name: 'پارچه کجراه',
|
||||
code: '401-KJ',
|
||||
color: 'کرم',
|
||||
minMeterage: 40,
|
||||
orderMultiplier: 10,
|
||||
isActive: true,
|
||||
stock: 4000,
|
||||
},
|
||||
]
|
||||
|
||||
export function getCompanyById(id: string): Company | undefined {
|
||||
return companies.find((c) => c.id === id)
|
||||
}
|
||||
|
||||
export function getCatalogsByCompany(companyId: string): Catalog[] {
|
||||
return catalogs
|
||||
.filter((c) => c.companyId === companyId && c.isActive)
|
||||
.sort((a, b) => a.displayOrder - b.displayOrder)
|
||||
}
|
||||
|
||||
export function getProductsByCatalog(catalogId: string): Product[] {
|
||||
return products.filter((p) => p.catalogId === catalogId && p.isActive)
|
||||
}
|
||||
|
||||
export function getBankAccountsByCompany(companyId: string): BankAccount[] {
|
||||
return bankAccounts
|
||||
.filter((b) => b.companyId === companyId && b.isActive)
|
||||
.sort((a, b) => a.displayOrder - b.displayOrder)
|
||||
}
|
||||
|
||||
export function getCatalogById(id: string): Catalog | undefined {
|
||||
return catalogs.find((c) => c.id === id)
|
||||
}
|
||||
|
||||
export function getProductById(id: string): Product | undefined {
|
||||
return products.find((p) => p.id === id)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import BottomNav from '@/components/layout/BottomNav.vue'
|
||||
import DesktopSidebar from '@/components/layout/DesktopSidebar.vue'
|
||||
import DesktopHeader from '@/components/layout/DesktopHeader.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const showNav = computed(() => {
|
||||
const noNavRoutes = [
|
||||
'login',
|
||||
'register',
|
||||
'otp',
|
||||
'settlement',
|
||||
'check-mode',
|
||||
'check-form',
|
||||
'check-list',
|
||||
'cash-settlement',
|
||||
'order-success',
|
||||
]
|
||||
return !noNavRoutes.includes(route.name as string) && !route.meta.hideNav
|
||||
})
|
||||
|
||||
const isWideGuest = computed(() => {
|
||||
const wideRoutes = [
|
||||
'settlement',
|
||||
'check-mode',
|
||||
'check-form',
|
||||
'check-list',
|
||||
'cash-settlement',
|
||||
]
|
||||
return wideRoutes.includes(route.name as string)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="app-shell"
|
||||
:class="{
|
||||
'app-shell--no-nav': !showNav,
|
||||
'app-shell--wide': isWideGuest,
|
||||
'app-shell--with-nav': showNav,
|
||||
}"
|
||||
>
|
||||
<div class="app-layout">
|
||||
<DesktopSidebar v-if="showNav" />
|
||||
<div class="app-frame">
|
||||
<DesktopHeader v-if="showNav" />
|
||||
<main class="app-main">
|
||||
<div class="app-main__content">
|
||||
<div class="app-content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<BottomNav v-if="showNav" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './assets/styles/fonts.css'
|
||||
import './assets/styles/main.css'
|
||||
import './assets/styles/app-shell.css'
|
||||
import './assets/styles/responsive.css'
|
||||
import './assets/styles/desktop.css'
|
||||
import './assets/styles/desktop-pages.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,135 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { getCurrentUser } from '@/services/authService'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/auth/LoginView.vue'),
|
||||
meta: { guest: true, hideNav: true },
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'register',
|
||||
component: () => import('@/views/auth/RegisterView.vue'),
|
||||
meta: { guest: true, hideNav: true },
|
||||
},
|
||||
{
|
||||
path: '/otp',
|
||||
name: 'otp',
|
||||
component: () => import('@/views/auth/OtpView.vue'),
|
||||
meta: { guest: true, hideNav: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: () => import('@/views/companies/CompanySelectView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/companies/:companyId/catalogs',
|
||||
name: 'catalogs',
|
||||
component: () => import('@/views/catalogs/CatalogListView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/companies/:companyId/catalogs/:catalogId/products',
|
||||
name: 'products',
|
||||
component: () => import('@/views/products/ProductListView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/cart',
|
||||
name: 'cart',
|
||||
component: () => import('@/views/cart/CartView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/checkout/settlement',
|
||||
name: 'settlement',
|
||||
component: () => import('@/views/checkout/SettlementTypeView.vue'),
|
||||
meta: { requiresAuth: true, hideNav: true },
|
||||
},
|
||||
{
|
||||
path: '/checkout/cash',
|
||||
name: 'cash-settlement',
|
||||
component: () => import('@/views/checkout/CashSettlementView.vue'),
|
||||
meta: { requiresAuth: true, hideNav: true },
|
||||
},
|
||||
{
|
||||
path: '/checkout/check-mode',
|
||||
name: 'check-mode',
|
||||
component: () => import('@/views/checkout/CheckModeSelectView.vue'),
|
||||
meta: { requiresAuth: true, hideNav: true },
|
||||
},
|
||||
{
|
||||
path: '/checkout/check-form',
|
||||
name: 'check-form',
|
||||
component: () => import('@/views/checkout/CheckFormView.vue'),
|
||||
meta: { requiresAuth: true, hideNav: true },
|
||||
},
|
||||
{
|
||||
path: '/checkout/check-list',
|
||||
name: 'check-list',
|
||||
component: () => import('@/views/checkout/CheckListView.vue'),
|
||||
meta: { requiresAuth: true, hideNav: true },
|
||||
},
|
||||
{
|
||||
path: '/orders',
|
||||
name: 'orders',
|
||||
component: () => import('@/views/OrdersView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/orders/:id',
|
||||
name: 'order-detail',
|
||||
component: () => import('@/views/orders/OrderDetailView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/orders/:id/success',
|
||||
name: 'order-success',
|
||||
component: () => import('@/views/orders/OrderSuccessView.vue'),
|
||||
meta: { requiresAuth: true, hideNav: true },
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'profile',
|
||||
component: () => import('@/views/profile/ProfileView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/profile/edit',
|
||||
name: 'profile-edit',
|
||||
component: () => import('@/views/profile/ProfileEditView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/profile/checks',
|
||||
name: 'profile-checks',
|
||||
component: () => import('@/views/profile/ProfileChecksView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
],
|
||||
scrollBehavior() {
|
||||
return { top: 0 }
|
||||
},
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const isAuthenticated = !!getCurrentUser()
|
||||
|
||||
if (to.meta.requiresAuth && !isAuthenticated) {
|
||||
return { name: 'login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
|
||||
if (to.meta.guest && isAuthenticated) {
|
||||
return { name: 'home' }
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,130 @@
|
||||
import { MOCK_OTP_CODE } from '@/utils/constants'
|
||||
import { generateId, normalizePhone } from '@/utils/format'
|
||||
import { getItem, setItem, STORAGE_KEYS } from './storage'
|
||||
import type { User } from '@/types'
|
||||
|
||||
interface PendingOtp {
|
||||
phone: string
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
isRegister: boolean
|
||||
code: string
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
export function getUsers(): User[] {
|
||||
return getItem<User[]>(STORAGE_KEYS.users, [])
|
||||
}
|
||||
|
||||
function saveUsers(users: User[]): void {
|
||||
setItem(STORAGE_KEYS.users, users)
|
||||
}
|
||||
|
||||
export function getSessionUserId(): string | null {
|
||||
return getItem<string | null>(STORAGE_KEYS.session, null)
|
||||
}
|
||||
|
||||
export function setSessionUserId(userId: string | null): void {
|
||||
setItem(STORAGE_KEYS.session, userId)
|
||||
}
|
||||
|
||||
export function getCurrentUser(): User | null {
|
||||
const id = getSessionUserId()
|
||||
if (!id) return null
|
||||
return getUsers().find((u) => u.id === id) ?? null
|
||||
}
|
||||
|
||||
export async function sendOtp(
|
||||
phone: string,
|
||||
options: { firstName?: string; lastName?: string; isRegister: boolean },
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const normalized = normalizePhone(phone)
|
||||
if (!/^09\d{9}$/.test(normalized)) {
|
||||
return { success: false, message: 'شماره موبایل معتبر نیست.' }
|
||||
}
|
||||
|
||||
if (options.isRegister) {
|
||||
if (!options.firstName?.trim() || !options.lastName?.trim()) {
|
||||
return { success: false, message: 'نام و نام خانوادگی الزامی است.' }
|
||||
}
|
||||
const exists = getUsers().some((u) => u.phone === normalized)
|
||||
if (exists) {
|
||||
return { success: false, message: 'این شماره قبلاً ثبت شده است. وارد شوید.' }
|
||||
}
|
||||
} else {
|
||||
const exists = getUsers().some((u) => u.phone === normalized)
|
||||
if (!exists) {
|
||||
return { success: false, message: 'کاربری با این شماره یافت نشد. ثبتنام کنید.' }
|
||||
}
|
||||
}
|
||||
|
||||
const pending: PendingOtp = {
|
||||
phone: normalized,
|
||||
firstName: options.firstName,
|
||||
lastName: options.lastName,
|
||||
isRegister: options.isRegister,
|
||||
code: MOCK_OTP_CODE,
|
||||
expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
|
||||
}
|
||||
setItem(STORAGE_KEYS.pendingOtp, pending)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `کد تأیید ارسال شد. (نسخه آزمایشی: ${MOCK_OTP_CODE})`,
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyOtp(
|
||||
phone: string,
|
||||
code: string,
|
||||
): Promise<{ success: boolean; message: string; user?: User }> {
|
||||
const pending = getItem<PendingOtp | null>(STORAGE_KEYS.pendingOtp, null)
|
||||
const normalized = normalizePhone(phone)
|
||||
|
||||
if (!pending || pending.phone !== normalized) {
|
||||
return { success: false, message: 'ابتدا درخواست کد دهید.' }
|
||||
}
|
||||
if (new Date(pending.expiresAt) < new Date()) {
|
||||
return { success: false, message: 'کد منقضی شده است. دوباره درخواست دهید.' }
|
||||
}
|
||||
if (code.trim() !== pending.code) {
|
||||
return { success: false, message: 'کد تأیید نادرست است.' }
|
||||
}
|
||||
|
||||
let user: User
|
||||
const users = getUsers()
|
||||
|
||||
if (pending.isRegister) {
|
||||
user = {
|
||||
id: generateId(),
|
||||
firstName: pending.firstName!,
|
||||
lastName: pending.lastName!,
|
||||
phone: normalized,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
users.push(user)
|
||||
saveUsers(users)
|
||||
} else {
|
||||
user = users.find((u) => u.phone === normalized)!
|
||||
}
|
||||
|
||||
setSessionUserId(user.id)
|
||||
setItem(STORAGE_KEYS.pendingOtp, null)
|
||||
return { success: true, message: 'ورود موفق.', user }
|
||||
}
|
||||
|
||||
export function logout(): void {
|
||||
setSessionUserId(null)
|
||||
}
|
||||
|
||||
export function updateProfile(
|
||||
userId: string,
|
||||
data: Partial<Pick<User, 'firstName' | 'lastName' | 'nationalId'>>,
|
||||
): User | null {
|
||||
const users = getUsers()
|
||||
const index = users.findIndex((u) => u.id === userId)
|
||||
if (index === -1) return null
|
||||
users[index] = { ...users[index]!, ...data }
|
||||
saveUsers(users)
|
||||
return users[index]!
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export function getItem<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) return fallback
|
||||
return JSON.parse(raw) as T
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export function setItem<T>(key: string, value: T): void {
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
}
|
||||
|
||||
export function removeItem(key: string): void {
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
|
||||
export const STORAGE_KEYS = {
|
||||
users: 'ft_users',
|
||||
session: 'ft_session',
|
||||
carts: 'ft_carts',
|
||||
activeCartId: 'ft_active_cart_id',
|
||||
orders: 'ft_orders',
|
||||
pendingOtp: 'ft_pending_otp',
|
||||
} as const
|
||||
@@ -0,0 +1,80 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { User } from '@/types'
|
||||
import {
|
||||
getCurrentUser,
|
||||
logout as authLogout,
|
||||
sendOtp,
|
||||
verifyOtp,
|
||||
updateProfile as authUpdateProfile,
|
||||
} from '@/services/authService'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref<User | null>(getCurrentUser())
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const isAuthenticated = computed(() => !!user.value)
|
||||
const fullName = computed(() =>
|
||||
user.value ? `${user.value.firstName} ${user.value.lastName}` : '',
|
||||
)
|
||||
|
||||
function clearError() {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
async function requestOtp(
|
||||
phone: string,
|
||||
options: { firstName?: string; lastName?: string; isRegister: boolean },
|
||||
) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const result = await sendOtp(phone, options)
|
||||
loading.value = false
|
||||
if (!result.success) error.value = result.message
|
||||
return result
|
||||
}
|
||||
|
||||
async function confirmOtp(phone: string, code: string) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const result = await verifyOtp(phone, code)
|
||||
loading.value = false
|
||||
if (result.success && result.user) {
|
||||
user.value = result.user
|
||||
} else {
|
||||
error.value = result.message
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function logout() {
|
||||
authLogout()
|
||||
user.value = null
|
||||
}
|
||||
|
||||
function updateProfile(data: Partial<Pick<User, 'firstName' | 'lastName' | 'nationalId'>>) {
|
||||
if (!user.value) return null
|
||||
const updated = authUpdateProfile(user.value.id, data)
|
||||
if (updated) user.value = updated
|
||||
return updated
|
||||
}
|
||||
|
||||
function hydrate() {
|
||||
user.value = getCurrentUser()
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
loading,
|
||||
error,
|
||||
isAuthenticated,
|
||||
fullName,
|
||||
clearError,
|
||||
requestOtp,
|
||||
confirmOtp,
|
||||
logout,
|
||||
updateProfile,
|
||||
hydrate,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,260 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Cart, CartItem, Company, Product } from '@/types'
|
||||
import { generateId } from '@/utils/format'
|
||||
import { validateMeterage } from '@/utils/validation'
|
||||
import { getItem, setItem, STORAGE_KEYS } from '@/services/storage'
|
||||
import { getCompanyById } from '@/data/mockData'
|
||||
|
||||
function loadCarts(): Cart[] {
|
||||
return getItem<Cart[]>(STORAGE_KEYS.carts, [])
|
||||
}
|
||||
|
||||
function saveCarts(carts: Cart[]) {
|
||||
setItem(STORAGE_KEYS.carts, carts)
|
||||
}
|
||||
|
||||
function loadActiveCartId(): string | null {
|
||||
return getItem<string | null>(STORAGE_KEYS.activeCartId, null)
|
||||
}
|
||||
|
||||
function saveActiveCartId(id: string | null) {
|
||||
setItem(STORAGE_KEYS.activeCartId, id)
|
||||
}
|
||||
|
||||
function getCartTotalMeterage(cart: Cart): number {
|
||||
return cart.items.reduce((sum, item) => sum + item.meterage, 0)
|
||||
}
|
||||
|
||||
function findOpenCartForCompany(carts: Cart[], company: Company): Cart | undefined {
|
||||
return carts.find(
|
||||
(cart) =>
|
||||
cart.companyId === company.id &&
|
||||
getCartTotalMeterage(cart) < company.maxMeteragePerInvoice,
|
||||
)
|
||||
}
|
||||
|
||||
export const useCartStore = defineStore('cart', () => {
|
||||
const carts = ref<Cart[]>(loadCarts())
|
||||
const activeCartId = ref<string | null>(loadActiveCartId())
|
||||
const selectedCompanyId = ref<string | null>(null)
|
||||
|
||||
const activeCart = computed(() => carts.value.find((c) => c.id === activeCartId.value) ?? null)
|
||||
|
||||
const totalMeterage = computed(
|
||||
() => activeCart.value?.items.reduce((sum, i) => sum + i.meterage, 0) ?? 0,
|
||||
)
|
||||
|
||||
const itemCount = computed(() => activeCart.value?.items.length ?? 0)
|
||||
|
||||
const maxMeterage = computed(() => {
|
||||
if (!activeCart.value) return 5000
|
||||
const company = getCompanyById(activeCart.value.companyId)
|
||||
return company?.maxMeteragePerInvoice ?? 5000
|
||||
})
|
||||
|
||||
const remainingCapacity = computed(() => Math.max(0, maxMeterage.value - totalMeterage.value))
|
||||
|
||||
const isFull = computed(() => totalMeterage.value >= maxMeterage.value)
|
||||
|
||||
function persist() {
|
||||
saveCarts(carts.value)
|
||||
saveActiveCartId(activeCartId.value)
|
||||
}
|
||||
|
||||
function initCartForCompany(company: Company): Cart {
|
||||
const existing = findOpenCartForCompany(carts.value, company)
|
||||
if (existing) {
|
||||
activeCartId.value = existing.id
|
||||
selectedCompanyId.value = company.id
|
||||
persist()
|
||||
return existing
|
||||
}
|
||||
|
||||
const cart: Cart = {
|
||||
id: generateId(),
|
||||
companyId: company.id,
|
||||
companyName: company.name,
|
||||
items: [],
|
||||
}
|
||||
carts.value.push(cart)
|
||||
activeCartId.value = cart.id
|
||||
selectedCompanyId.value = company.id
|
||||
persist()
|
||||
return cart
|
||||
}
|
||||
|
||||
function switchCompanyWarning(currentCompanyName: string, newCompanyName: string): string {
|
||||
return `سبد خرید فعلی مربوط به ${currentCompanyName} است. برای خرید از ${newCompanyName} باید سبد خرید جداگانهای ایجاد کنید.`
|
||||
}
|
||||
|
||||
function canSwitchToCompany(companyId: string): { allowed: boolean; message?: string } {
|
||||
if (!activeCart.value || activeCart.value.items.length === 0) {
|
||||
return { allowed: true }
|
||||
}
|
||||
if (activeCart.value.companyId === companyId) {
|
||||
return { allowed: true }
|
||||
}
|
||||
const newCompany = getCompanyById(companyId)
|
||||
return {
|
||||
allowed: false,
|
||||
message: switchCompanyWarning(activeCart.value.companyName, newCompany?.name ?? ''),
|
||||
}
|
||||
}
|
||||
|
||||
function createNewCartForSameCompany(): Cart | null {
|
||||
if (!activeCart.value) return null
|
||||
const company = getCompanyById(activeCart.value.companyId)
|
||||
if (!company) return null
|
||||
const cart: Cart = {
|
||||
id: generateId(),
|
||||
companyId: company.id,
|
||||
companyName: company.name,
|
||||
items: [],
|
||||
}
|
||||
carts.value.push(cart)
|
||||
activeCartId.value = cart.id
|
||||
persist()
|
||||
return cart
|
||||
}
|
||||
|
||||
function addItem(
|
||||
product: Product,
|
||||
catalogCode: string,
|
||||
meterage: number,
|
||||
): { success: boolean; message?: string } {
|
||||
if (!activeCart.value) {
|
||||
return { success: false, message: 'ابتدا شرکت را انتخاب کنید.' }
|
||||
}
|
||||
|
||||
if (activeCart.value.companyId !== product.companyId) {
|
||||
const currentCompany = activeCart.value.companyName
|
||||
const newCompany = getCompanyById(product.companyId)
|
||||
return {
|
||||
success: false,
|
||||
message: switchCompanyWarning(currentCompany, newCompany?.name ?? ''),
|
||||
}
|
||||
}
|
||||
|
||||
const validation = validateMeterage(meterage, product, totalMeterage.value, maxMeterage.value)
|
||||
if (!validation.valid) {
|
||||
return { success: false, message: validation.message }
|
||||
}
|
||||
|
||||
const existingIndex = activeCart.value.items.findIndex((i) => i.productId === product.id)
|
||||
if (existingIndex >= 0) {
|
||||
const existing = activeCart.value.items[existingIndex]!
|
||||
const revalidation = validateMeterage(
|
||||
meterage,
|
||||
product,
|
||||
totalMeterage.value,
|
||||
maxMeterage.value,
|
||||
existing.meterage,
|
||||
)
|
||||
if (!revalidation.valid) {
|
||||
return { success: false, message: revalidation.message }
|
||||
}
|
||||
existing.meterage = meterage
|
||||
} else {
|
||||
const item: CartItem = {
|
||||
id: generateId(),
|
||||
productId: product.id,
|
||||
catalogCode,
|
||||
productName: product.name,
|
||||
productCode: product.code,
|
||||
color: product.color,
|
||||
meterage,
|
||||
unitPrice: product.basePrice,
|
||||
}
|
||||
activeCart.value.items.push(item)
|
||||
}
|
||||
|
||||
persist()
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
function updateItemMeterage(
|
||||
itemId: string,
|
||||
meterage: number,
|
||||
product: Product,
|
||||
): { success: boolean; message?: string } {
|
||||
if (!activeCart.value) return { success: false, message: 'سبد خرید یافت نشد.' }
|
||||
const item = activeCart.value.items.find((i) => i.id === itemId)
|
||||
if (!item) return { success: false, message: 'آیتم یافت نشد.' }
|
||||
|
||||
const validation = validateMeterage(
|
||||
meterage,
|
||||
product,
|
||||
totalMeterage.value,
|
||||
maxMeterage.value,
|
||||
item.meterage,
|
||||
)
|
||||
if (!validation.valid) {
|
||||
return { success: false, message: validation.message }
|
||||
}
|
||||
|
||||
item.meterage = meterage
|
||||
persist()
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
function removeItem(itemId: string) {
|
||||
if (!activeCart.value) return
|
||||
activeCart.value.items = activeCart.value.items.filter((i) => i.id !== itemId)
|
||||
persist()
|
||||
}
|
||||
|
||||
function setNotes(notes: string) {
|
||||
if (!activeCart.value) return
|
||||
activeCart.value.notes = notes
|
||||
persist()
|
||||
}
|
||||
|
||||
function clearActiveCart() {
|
||||
if (!activeCart.value) return
|
||||
activeCart.value.items = []
|
||||
activeCart.value.notes = undefined
|
||||
persist()
|
||||
}
|
||||
|
||||
function ensureActiveCartForCartPage() {
|
||||
if (activeCart.value?.items.length) return
|
||||
|
||||
const cartWithItems = carts.value.find((cart) => cart.items.length > 0)
|
||||
if (!cartWithItems) return
|
||||
|
||||
activeCartId.value = cartWithItems.id
|
||||
selectedCompanyId.value = cartWithItems.companyId
|
||||
persist()
|
||||
}
|
||||
|
||||
function hydrate() {
|
||||
carts.value = loadCarts()
|
||||
activeCartId.value = loadActiveCartId()
|
||||
if (activeCart.value) {
|
||||
selectedCompanyId.value = activeCart.value.companyId
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
carts,
|
||||
activeCartId,
|
||||
selectedCompanyId,
|
||||
activeCart,
|
||||
totalMeterage,
|
||||
itemCount,
|
||||
maxMeterage,
|
||||
remainingCapacity,
|
||||
isFull,
|
||||
initCartForCompany,
|
||||
canSwitchToCompany,
|
||||
createNewCartForSameCompany,
|
||||
addItem,
|
||||
updateItemMeterage,
|
||||
removeItem,
|
||||
setNotes,
|
||||
clearActiveCart,
|
||||
ensureActiveCartForCartPage,
|
||||
hydrate,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
companies as mockCompanies,
|
||||
getCatalogsByCompany,
|
||||
getProductsByCatalog,
|
||||
getCatalogById,
|
||||
getProductById,
|
||||
getCompanyById,
|
||||
} from '@/data/mockData'
|
||||
import type { Catalog, Company, Product } from '@/types'
|
||||
|
||||
export const useCatalogStore = defineStore('catalog', () => {
|
||||
const companies = ref<Company[]>(mockCompanies.filter((c) => c.isActive))
|
||||
const searchQuery = ref('')
|
||||
|
||||
function getActiveCompanies(): Company[] {
|
||||
return [...companies.value].sort((a, b) => a.displayOrder - b.displayOrder)
|
||||
}
|
||||
|
||||
function getCompany(id: string): Company | undefined {
|
||||
return getCompanyById(id)
|
||||
}
|
||||
|
||||
function fetchCatalogs(companyId: string, query = ''): Catalog[] {
|
||||
let list = getCatalogsByCompany(companyId)
|
||||
if (query.trim()) {
|
||||
const q = query.trim()
|
||||
list = list.filter((c) => c.code.includes(q) || c.name.includes(q))
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
function fetchProducts(catalogId: string, colorFilter?: string): Product[] {
|
||||
let list = getProductsByCatalog(catalogId)
|
||||
if (colorFilter && colorFilter !== 'all') {
|
||||
list = list.filter((p) => p.color.includes(colorFilter))
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
function getCatalog(id: string) {
|
||||
return getCatalogById(id)
|
||||
}
|
||||
|
||||
function getProduct(id: string) {
|
||||
return getProductById(id)
|
||||
}
|
||||
|
||||
function getUniqueColors(catalogId: string): string[] {
|
||||
const products = getProductsByCatalog(catalogId)
|
||||
return [...new Set(products.map((p) => p.color))]
|
||||
}
|
||||
|
||||
return {
|
||||
companies,
|
||||
searchQuery,
|
||||
getActiveCompanies,
|
||||
getCompany,
|
||||
fetchCatalogs,
|
||||
fetchProducts,
|
||||
getCatalog,
|
||||
getProduct,
|
||||
getUniqueColors,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,248 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type {
|
||||
Cart,
|
||||
CheckData,
|
||||
CashPaymentReceipt,
|
||||
Order,
|
||||
OrderItem,
|
||||
OrderStatus,
|
||||
OrderStatusHistoryEntry,
|
||||
SettlementType,
|
||||
CheckRegistrationMode,
|
||||
} from '@/types'
|
||||
import { generateId, generateOrderNumber } from '@/utils/format'
|
||||
import { calculateCheckRass } from '@/utils/checkRass'
|
||||
import { getItem, setItem, STORAGE_KEYS } from '@/services/storage'
|
||||
import { getCompanyById, getBankAccountsByCompany } from '@/data/mockData'
|
||||
|
||||
function loadOrders(): Order[] {
|
||||
return getItem<Order[]>(STORAGE_KEYS.orders, [])
|
||||
}
|
||||
|
||||
function saveOrders(orders: Order[]) {
|
||||
setItem(STORAGE_KEYS.orders, orders)
|
||||
}
|
||||
|
||||
function createStatusEntry(
|
||||
from: OrderStatus | null,
|
||||
to: OrderStatus,
|
||||
note?: string,
|
||||
): OrderStatusHistoryEntry {
|
||||
return {
|
||||
id: generateId(),
|
||||
fromStatus: from,
|
||||
toStatus: to,
|
||||
changedAt: new Date().toISOString(),
|
||||
changedBy: 'customer',
|
||||
note,
|
||||
}
|
||||
}
|
||||
|
||||
function cartToOrderItems(cart: Cart): OrderItem[] {
|
||||
return cart.items.map((item) => ({
|
||||
id: generateId(),
|
||||
productId: item.productId,
|
||||
catalogCode: item.catalogCode,
|
||||
productName: item.productName,
|
||||
productCode: item.productCode,
|
||||
color: item.color,
|
||||
requestedMeterage: item.meterage,
|
||||
unitPrice: item.unitPrice,
|
||||
notes: item.notes,
|
||||
}))
|
||||
}
|
||||
|
||||
function computeCashExpiry(companyId: string): string {
|
||||
const company = getCompanyById(companyId)
|
||||
const now = new Date()
|
||||
if (company?.cashInvoiceValidUntilHour) {
|
||||
const expiry = new Date(now)
|
||||
expiry.setHours(company.cashInvoiceValidUntilHour, 0, 0, 0)
|
||||
if (expiry <= now) {
|
||||
expiry.setDate(expiry.getDate() + 1)
|
||||
}
|
||||
return expiry.toISOString()
|
||||
}
|
||||
const hours = company?.cashInvoiceValidityHours ?? 8
|
||||
return new Date(now.getTime() + hours * 60 * 60 * 1000).toISOString()
|
||||
}
|
||||
|
||||
export const useOrderStore = defineStore('order', () => {
|
||||
const orders = ref<Order[]>(loadOrders())
|
||||
const draftChecks = ref<CheckData[]>([])
|
||||
const draftSettlementType = ref<SettlementType | null>(null)
|
||||
const draftCheckMode = ref<CheckRegistrationMode | null>(null)
|
||||
const checkoutCartId = ref<string | null>(null)
|
||||
|
||||
const userOrders = computed(
|
||||
() => (userId: string) =>
|
||||
orders.value
|
||||
.filter((o) => o.userId === userId)
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
|
||||
)
|
||||
|
||||
function persist() {
|
||||
saveOrders(orders.value)
|
||||
}
|
||||
|
||||
function getOrderById(id: string): Order | undefined {
|
||||
return orders.value.find((o) => o.id === id)
|
||||
}
|
||||
|
||||
function startCheckout(cart: Cart, settlementType: SettlementType) {
|
||||
checkoutCartId.value = cart.id
|
||||
draftSettlementType.value = settlementType
|
||||
draftChecks.value = []
|
||||
draftCheckMode.value = null
|
||||
}
|
||||
|
||||
function setCheckMode(mode: CheckRegistrationMode) {
|
||||
draftCheckMode.value = mode
|
||||
}
|
||||
|
||||
function addDraftCheck(check: Omit<CheckData, 'id'>) {
|
||||
draftChecks.value.push({ ...check, id: generateId() })
|
||||
}
|
||||
|
||||
function updateDraftCheck(id: string, data: Partial<CheckData>) {
|
||||
const index = draftChecks.value.findIndex((c) => c.id === id)
|
||||
if (index >= 0) {
|
||||
draftChecks.value[index] = { ...draftChecks.value[index]!, ...data }
|
||||
}
|
||||
}
|
||||
|
||||
function removeDraftCheck(id: string) {
|
||||
draftChecks.value = draftChecks.value.filter((c) => c.id !== id)
|
||||
}
|
||||
|
||||
function getDraftRass(baseDate?: Date) {
|
||||
return calculateCheckRass(draftChecks.value, baseDate ?? new Date())
|
||||
}
|
||||
|
||||
function submitOrder(
|
||||
userId: string,
|
||||
cart: Cart,
|
||||
settlementType: SettlementType,
|
||||
checks?: CheckData[],
|
||||
checkMode?: CheckRegistrationMode,
|
||||
): Order {
|
||||
const items = cartToOrderItems(cart)
|
||||
const totalMeterage = items.reduce((s, i) => s + i.requestedMeterage, 0)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
let status: OrderStatus = 'awaiting_review'
|
||||
let checkRassDays: number | undefined
|
||||
let checkRassDate: string | undefined
|
||||
let cashInvoiceExpiresAt: string | undefined
|
||||
|
||||
if (settlementType === 'check' && checks && checks.length > 0) {
|
||||
const rass = calculateCheckRass(checks, new Date())
|
||||
checkRassDays = rass.rassDays
|
||||
checkRassDate = rass.rassDate ?? undefined
|
||||
status = 'awaiting_review'
|
||||
}
|
||||
|
||||
if (settlementType === 'cash') {
|
||||
cashInvoiceExpiresAt = computeCashExpiry(cart.companyId)
|
||||
status = 'awaiting_review'
|
||||
}
|
||||
|
||||
const order: Order = {
|
||||
id: generateId(),
|
||||
orderNumber: generateOrderNumber(),
|
||||
userId,
|
||||
companyId: cart.companyId,
|
||||
companyName: cart.companyName,
|
||||
items,
|
||||
totalMeterage,
|
||||
settlementType,
|
||||
checkMode,
|
||||
checks: checks ? [...checks] : undefined,
|
||||
checkRassDays,
|
||||
checkRassDate,
|
||||
status,
|
||||
notes: cart.notes,
|
||||
cashInvoiceExpiresAt,
|
||||
statusHistory: [
|
||||
createStatusEntry(null, 'submitted', 'سفارش ثبت شد'),
|
||||
createStatusEntry('submitted', status, 'ارسال برای بررسی مدیریت'),
|
||||
],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
orders.value.unshift(order)
|
||||
persist()
|
||||
|
||||
draftChecks.value = []
|
||||
draftSettlementType.value = null
|
||||
draftCheckMode.value = null
|
||||
checkoutCartId.value = null
|
||||
|
||||
return order
|
||||
}
|
||||
|
||||
function submitCashReceipt(
|
||||
orderId: string,
|
||||
receipt: Omit<CashPaymentReceipt, 'id' | 'submittedAt'>,
|
||||
) {
|
||||
const order = getOrderById(orderId)
|
||||
if (!order) return null
|
||||
|
||||
const fullReceipt: CashPaymentReceipt = {
|
||||
...receipt,
|
||||
id: generateId(),
|
||||
submittedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const prevStatus = order.status
|
||||
order.cashReceipt = fullReceipt
|
||||
order.status = 'awaiting_cash_confirmation'
|
||||
order.statusHistory.push(
|
||||
createStatusEntry(prevStatus, 'awaiting_cash_confirmation', 'رسید پرداخت ثبت شد'),
|
||||
)
|
||||
order.updatedAt = new Date().toISOString()
|
||||
persist()
|
||||
return order
|
||||
}
|
||||
|
||||
function updateOrderChecks(orderId: string, checks: CheckData[]) {
|
||||
const order = getOrderById(orderId)
|
||||
if (!order) return null
|
||||
|
||||
const rass = calculateCheckRass(checks, new Date(order.createdAt))
|
||||
order.checks = checks
|
||||
order.checkRassDays = rass.rassDays
|
||||
order.checkRassDate = rass.rassDate ?? undefined
|
||||
order.updatedAt = new Date().toISOString()
|
||||
persist()
|
||||
return order
|
||||
}
|
||||
|
||||
function hydrate() {
|
||||
orders.value = loadOrders()
|
||||
}
|
||||
|
||||
return {
|
||||
orders,
|
||||
draftChecks,
|
||||
draftSettlementType,
|
||||
draftCheckMode,
|
||||
checkoutCartId,
|
||||
userOrders,
|
||||
persist,
|
||||
getOrderById,
|
||||
startCheckout,
|
||||
setCheckMode,
|
||||
addDraftCheck,
|
||||
updateDraftCheck,
|
||||
removeDraftCheck,
|
||||
getDraftRass,
|
||||
submitOrder,
|
||||
submitCashReceipt,
|
||||
updateOrderChecks,
|
||||
hydrate,
|
||||
getBankAccountsForCompany: getBankAccountsByCompany,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
export type SettlementType = 'cash' | 'check'
|
||||
export type CheckRegistrationMode = 'full' | 'brief'
|
||||
|
||||
export type OrderStatus =
|
||||
| 'cart'
|
||||
| 'awaiting_settlement'
|
||||
| 'cash_in_progress'
|
||||
| 'check_in_progress'
|
||||
| 'submitted'
|
||||
| 'awaiting_review'
|
||||
| 'pricing'
|
||||
| 'needs_correction'
|
||||
| 'awaiting_settlement_fix'
|
||||
| 'cash_invoice_issued'
|
||||
| 'awaiting_cash_payment'
|
||||
| 'cash_payment_submitted'
|
||||
| 'awaiting_cash_confirmation'
|
||||
| 'cash_confirmed'
|
||||
| 'awaiting_check_review'
|
||||
| 'check_needs_fix'
|
||||
| 'check_rejected'
|
||||
| 'checks_approved'
|
||||
| 'final_approved'
|
||||
| 'preparing'
|
||||
| 'ready_to_ship'
|
||||
| 'shipped'
|
||||
| 'delivered'
|
||||
| 'cancelled'
|
||||
| 'rejected'
|
||||
| 'expired'
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
phone: string
|
||||
nationalId?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface Company {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
logo?: string
|
||||
description: string
|
||||
isActive: boolean
|
||||
displayOrder: number
|
||||
maxMeteragePerInvoice: number
|
||||
cashInvoiceValidityHours: number
|
||||
cashInvoiceValidUntilHour?: number
|
||||
}
|
||||
|
||||
export interface BankAccount {
|
||||
id: string
|
||||
companyId: string
|
||||
bankName: string
|
||||
accountHolder: string
|
||||
accountNumber: string
|
||||
cardNumber: string
|
||||
iban: string
|
||||
isActive: boolean
|
||||
displayOrder: number
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface Catalog {
|
||||
id: string
|
||||
companyId: string
|
||||
name: string
|
||||
code: string
|
||||
description?: string
|
||||
displayOrder: number
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: string
|
||||
companyId: string
|
||||
catalogId: string
|
||||
name: string
|
||||
code: string
|
||||
color: string
|
||||
description?: string
|
||||
minMeterage: number
|
||||
orderMultiplier: number
|
||||
basePrice?: number
|
||||
stock?: number
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export interface CartItem {
|
||||
id: string
|
||||
productId: string
|
||||
catalogCode: string
|
||||
productName: string
|
||||
productCode: string
|
||||
color: string
|
||||
meterage: number
|
||||
unitPrice?: number
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface Cart {
|
||||
id: string
|
||||
companyId: string
|
||||
companyName: string
|
||||
items: CartItem[]
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface CheckData {
|
||||
id: string
|
||||
amount: number
|
||||
dueDate: string
|
||||
sayadId?: string
|
||||
checkNumber?: string
|
||||
ownerNationalId?: string
|
||||
ownerFirstName?: string
|
||||
ownerLastName?: string
|
||||
bankName?: string
|
||||
branchName?: string
|
||||
frontImage?: string
|
||||
backImage?: string
|
||||
notes?: string
|
||||
mode: CheckRegistrationMode
|
||||
}
|
||||
|
||||
export interface CashPaymentReceipt {
|
||||
id: string
|
||||
amount: number
|
||||
transferDate: string
|
||||
transferTime: string
|
||||
trackingNumber: string
|
||||
destinationAccountId: string
|
||||
receiptImage?: string
|
||||
notes?: string
|
||||
submittedAt: string
|
||||
}
|
||||
|
||||
export interface OrderItem {
|
||||
id: string
|
||||
productId: string
|
||||
catalogCode: string
|
||||
productName: string
|
||||
productCode: string
|
||||
color: string
|
||||
requestedMeterage: number
|
||||
confirmedMeterage?: number
|
||||
unitPrice?: number
|
||||
discount?: number
|
||||
lineTotal?: number
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface OrderStatusHistoryEntry {
|
||||
id: string
|
||||
fromStatus: OrderStatus | null
|
||||
toStatus: OrderStatus
|
||||
changedAt: string
|
||||
changedBy: string
|
||||
note?: string
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: string
|
||||
orderNumber: string
|
||||
userId: string
|
||||
companyId: string
|
||||
companyName: string
|
||||
items: OrderItem[]
|
||||
totalMeterage: number
|
||||
settlementType: SettlementType
|
||||
checkMode?: CheckRegistrationMode
|
||||
checks?: CheckData[]
|
||||
checkRassDays?: number
|
||||
checkRassDate?: string
|
||||
status: OrderStatus
|
||||
notes?: string
|
||||
totalAmount?: number
|
||||
shippingCost?: number
|
||||
totalDiscount?: number
|
||||
finalAmount?: number
|
||||
cashInvoiceExpiresAt?: string
|
||||
cashReceipt?: CashPaymentReceipt
|
||||
statusHistory: OrderStatusHistoryEntry[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CheckRassResult {
|
||||
checkCount: number
|
||||
totalAmount: number
|
||||
nearestDueDate: string | null
|
||||
farthestDueDate: string | null
|
||||
rassDays: number
|
||||
rassDate: string | null
|
||||
}
|
||||
|
||||
export interface MeterageValidationResult {
|
||||
valid: boolean
|
||||
message?: string
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user