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
+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>