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:
2026-08-03 09:57:24 +02:00
co-authored by Cursor
commit fd3e5e5126
142 changed files with 19798 additions and 0 deletions
+8
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+39
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue"],
"env": {
"browser": true
},
"categories": {
"correctness": "error"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}
+9
View File
@@ -0,0 +1,9 @@
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"oxc.oxc-vscode",
"esbenp.prettier-vscode"
]
}
+48
View File
@@ -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
```
Vendored
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+26
View File
@@ -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
View File
@@ -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>
+5167
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -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
View File
@@ -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>
+128
View File
@@ -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);
}
}
+230
View File
@@ -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));
}
}
+652
View File
@@ -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));
}
}
+55
View File
@@ -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;
}
+350
View File
@@ -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;
}
}
+362
View File
@@ -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;
}
}
+20
View File
@@ -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>
+14
View File
@@ -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>
+21
View File
@@ -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>
+30
View File
@@ -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>
+26
View File
@@ -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>
+19
View File
@@ -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>
+19
View File
@@ -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>
+28
View File
@@ -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>
+28
View File
@@ -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>
+19
View File
@@ -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>
+28
View File
@@ -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>
+17
View File
@@ -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>
+26
View File
@@ -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>
+27
View File
@@ -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>
+16
View File
@@ -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>
+27
View File
@@ -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>
+28
View File
@@ -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>
+11
View File
@@ -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>
+25
View File
@@ -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>
+26
View File
@@ -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>
+15
View File
@@ -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>
+10
View File
@@ -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>
+15
View File
@@ -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>
+23
View File
@@ -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>
+41
View File
@@ -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>
+26
View File
@@ -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>
+14
View File
@@ -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>
+28
View File
@@ -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>
+20
View File
@@ -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>
+15
View File
@@ -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>
+20
View File
@@ -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>
+48
View File
@@ -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>
+16
View File
@@ -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>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import DefaultLayout from '@/layouts/DefaultLayout.vue'
</script>
<template>
<DefaultLayout>
<slot />
</DefaultLayout>
</template>
+108
View File
@@ -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>
+141
View File
@@ -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>
+292
View File
@@ -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>
+296
View File
@@ -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>
+148
View File
@@ -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>
+76
View File
@@ -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>
+52
View File
@@ -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>
+110
View File
@@ -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>
+67
View File
@@ -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>
+64
View File
@@ -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>
+95
View File
@@ -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>
+84
View File
@@ -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>
+52
View File
@@ -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>
+90
View File
@@ -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>
+25
View File
@@ -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>
+73
View File
@@ -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>
+328
View File
@@ -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>
+47
View File
@@ -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>
+17
View File
@@ -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>
+56
View File
@@ -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,
}
}
+155
View File
@@ -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,
}
}
+94
View File
@@ -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,
}
}
+248
View File
@@ -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)
}
+61
View File
@@ -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
View File
@@ -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')
+135
View File
@@ -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
+130
View File
@@ -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]!
}
+26
View File
@@ -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
+80
View File
@@ -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,
}
})
+260
View File
@@ -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,
}
})
+66
View File
@@ -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,
}
})
+248
View File
@@ -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,
}
})
+204
View File
@@ -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