Initial commit: fabric-trade-user Vue 3 app with mobile and desktop layouts
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
variant?: 'warning' | 'success' | 'info' | 'purple' | 'danger' | 'neutral'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="badge" :class="`badge-${variant ?? 'neutral'}`">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: var(--color-warning-light);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: var(--color-success-light);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background: var(--color-info-light);
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.badge-purple {
|
||||
background: var(--color-purple-light);
|
||||
color: var(--color-purple);
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background: var(--color-danger-light);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.badge-neutral {
|
||||
background: #f1f5f9;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
block?: boolean
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
type?: 'button' | 'submit'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:type="type ?? 'button'"
|
||||
class="btn"
|
||||
:class="[`btn-${variant ?? 'primary'}`, `btn-${size ?? 'md'}`, { 'btn-block': block }]"
|
||||
:disabled="disabled || loading"
|
||||
>
|
||||
<span v-if="loading" class="btn-spinner" />
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
opacity 0.15s;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
min-height: 36px;
|
||||
padding: 0 14px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.btn-md {
|
||||
min-height: var(--btn-height);
|
||||
padding: 0 24px;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
min-height: 52px;
|
||||
padding: 0 28px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:not(:disabled):hover {
|
||||
background: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
border: 1.5px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--color-danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.btn-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
modelValue?: string | number
|
||||
label?: string
|
||||
type?: string
|
||||
placeholder?: string
|
||||
hint?: string
|
||||
error?: string
|
||||
disabled?: boolean
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="form-group">
|
||||
<label v-if="label" class="form-label">{{ label }}</label>
|
||||
<input
|
||||
class="input"
|
||||
:class="{ 'input-error': error }"
|
||||
:type="type ?? 'text'"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<p v-if="error" class="input-error-text">{{ error }}</p>
|
||||
<p v-else-if="hint" class="form-hint">{{ hint }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.input {
|
||||
width: 100%;
|
||||
height: var(--btn-height);
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.9375rem;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.input-error {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.input-error-text {
|
||||
color: var(--color-danger);
|
||||
font-size: 0.8125rem;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { formatMeterage } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
current: number
|
||||
max: number
|
||||
}>()
|
||||
|
||||
const remaining = computed(() => Math.max(0, props.max - props.current))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="capacity-summary">
|
||||
<div class="capacity-summary__row">
|
||||
<span class="capacity-summary__label">جمع متراژ</span>
|
||||
<span class="capacity-summary__value">{{ formatMeterage(current) }}</span>
|
||||
</div>
|
||||
<div class="capacity-summary__row">
|
||||
<span class="capacity-summary__label">حداکثر مجاز</span>
|
||||
<span class="capacity-summary__value">{{ formatMeterage(max) }}</span>
|
||||
</div>
|
||||
<div class="capacity-summary__row">
|
||||
<span class="capacity-summary__label">باقیمانده</span>
|
||||
<span class="capacity-summary__value capacity-summary__value--accent">
|
||||
{{ formatMeterage(remaining) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.capacity-summary {
|
||||
background: var(--color-summary-bg);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.capacity-summary__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.capacity-summary__row:not(:last-child) {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.capacity-summary__label {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.capacity-summary__value {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.capacity-summary__value--accent {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { formatMeterage } from '@/utils/format'
|
||||
import IconCart from '@/components/icons/IconCart.vue'
|
||||
|
||||
defineProps<{
|
||||
totalMeterage: number
|
||||
itemCount?: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cart-meterage-footer">
|
||||
<div class="cart-meterage-footer__info">
|
||||
<span class="cart-meterage-footer__label">کل متراژ</span>
|
||||
<span class="cart-meterage-footer__value">{{ formatMeterage(totalMeterage) }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="cart-meterage-footer__btn"
|
||||
aria-label="مشاهده سبد"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<IconCart :size="22" />
|
||||
<span v-if="itemCount" class="cart-meterage-footer__badge">{{ itemCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cart-meterage-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.cart-meterage-footer__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.cart-meterage-footer__label {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.cart-meterage-footer__value {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cart-meterage-footer__btn {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.cart-meterage-footer__btn:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.cart-meterage-footer__badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: -4px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-danger);
|
||||
color: #fff;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { extractNumericString, formatNumber } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
hint?: string
|
||||
error?: string
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const displayValue = ref('')
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
const numeric = extractNumericString(value ?? '')
|
||||
displayValue.value = numeric ? formatNumber(Number(numeric)) : ''
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function onInput(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const numeric = extractNumericString(input.value)
|
||||
displayValue.value = numeric ? formatNumber(Number(numeric)) : ''
|
||||
emit('update:modelValue', numeric)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="form-group">
|
||||
<label v-if="label" class="form-label">{{ label }}</label>
|
||||
<input
|
||||
class="input"
|
||||
:class="{ 'input-error': error }"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
dir="ltr"
|
||||
:value="displayValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
@input="onInput"
|
||||
/>
|
||||
<p v-if="error" class="input-error-text">{{ error }}</p>
|
||||
<p v-else-if="hint" class="form-hint">{{ hint }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.input {
|
||||
width: 100%;
|
||||
height: var(--btn-height);
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.9375rem;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
transition: border-color 0.15s;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.input-error {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.input-error-text {
|
||||
color: var(--color-danger);
|
||||
font-size: 0.8125rem;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import IconClipboardEmpty from '@/components/icons/IconClipboardEmpty.vue'
|
||||
|
||||
defineProps<{
|
||||
message?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state__icon">
|
||||
<slot name="icon">
|
||||
<IconClipboardEmpty :size="72" />
|
||||
</slot>
|
||||
</div>
|
||||
<p class="empty-state__message">
|
||||
<slot name="message">{{ message }}</slot>
|
||||
</p>
|
||||
<div v-if="$slots.default" class="empty-state__action">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
padding: 48px 24px;
|
||||
min-height: 320px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state__icon {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-state__message {
|
||||
margin: 0 0 24px;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.empty-state__action {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
tabs: { id: string; label: string }[]
|
||||
equal?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="filter-tabs" :class="{ 'filter-tabs--equal': equal }" role="tablist">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
type="button"
|
||||
role="tab"
|
||||
class="filter-tab"
|
||||
:class="{ active: modelValue === tab.id }"
|
||||
:aria-selected="modelValue === tab.id"
|
||||
@click="$emit('update:modelValue', tab.id)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.filter-tabs--equal {
|
||||
gap: 6px;
|
||||
overflow: visible;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.filter-tab {
|
||||
flex-shrink: 0;
|
||||
height: 36px;
|
||||
padding: 0 18px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: #f5f5f5;
|
||||
color: #64748b;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.filter-tabs--equal .filter-tab {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 38px;
|
||||
padding: 0 6px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.filter-tab.active {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter-tabs--equal .filter-tab.active {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter-tab:not(.active):hover {
|
||||
background: #eceff1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
message?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="message" class="form-error-banner" role="alert">
|
||||
{{ message }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-error-banner {
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: var(--color-danger);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { formatMeterage } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
current: number
|
||||
max: number
|
||||
}>()
|
||||
|
||||
const remaining = computed(() => Math.max(0, props.max - props.current))
|
||||
const percentage = computed(() => Math.min(100, (props.current / props.max) * 100))
|
||||
const isNearFull = computed(() => percentage.value >= 80)
|
||||
const isFull = computed(() => props.current >= props.max)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="meterage-bar" :class="{ near: isNearFull, full: isFull }">
|
||||
<div class="meterage-info">
|
||||
<span
|
||||
>متراژ سبد: <strong>{{ formatMeterage(current) }}</strong></span
|
||||
>
|
||||
<span
|
||||
>باقیمانده: <strong>{{ formatMeterage(remaining) }}</strong></span
|
||||
>
|
||||
</div>
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" :style="{ width: `${percentage}%` }" />
|
||||
</div>
|
||||
<p class="meterage-max text-sm text-muted">حداکثر مجاز: {{ formatMeterage(max) }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.meterage-bar {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.meterage-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 8px;
|
||||
background: #e2e8f0;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--color-primary);
|
||||
border-radius: 999px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.meterage-bar.near .progress-fill {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
.meterage-bar.full .progress-fill {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
.meterage-max {
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,328 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
JALALI_MONTH_NAMES,
|
||||
JALALI_WEEKDAY_NAMES,
|
||||
buildCalendarDays,
|
||||
formatJalaliLabel,
|
||||
isoToJalali,
|
||||
jalaliToISO,
|
||||
todayJalali,
|
||||
} from '@/utils/jalali'
|
||||
import { toPersianDigits } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
hint?: string
|
||||
error?: string
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const isOpen = ref(false)
|
||||
const viewYear = ref(todayJalali().jy)
|
||||
const viewMonth = ref(todayJalali().jm)
|
||||
|
||||
const selectedJalali = computed(() => isoToJalali(props.modelValue ?? ''))
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (!selectedJalali.value) return ''
|
||||
const { jy, jm, jd } = selectedJalali.value
|
||||
return formatJalaliLabel(jy, jm, jd)
|
||||
})
|
||||
|
||||
const calendarDays = computed(() => buildCalendarDays(viewYear.value, viewMonth.value))
|
||||
|
||||
const monthLabel = computed(
|
||||
() => `${JALALI_MONTH_NAMES[viewMonth.value - 1]} ${toPersianDigits(String(viewYear.value))}`,
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
const jalali = isoToJalali(value ?? '')
|
||||
if (jalali) {
|
||||
viewYear.value = jalali.jy
|
||||
viewMonth.value = jalali.jm
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function toggleCalendar() {
|
||||
if (props.disabled) return
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
function closeCalendar() {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function selectDay(day: number) {
|
||||
emit('update:modelValue', jalaliToISO(viewYear.value, viewMonth.value, day))
|
||||
closeCalendar()
|
||||
}
|
||||
|
||||
function changeMonth(delta: number) {
|
||||
let month = viewMonth.value + delta
|
||||
let year = viewYear.value
|
||||
|
||||
if (month > 12) {
|
||||
month = 1
|
||||
year += 1
|
||||
} else if (month < 1) {
|
||||
month = 12
|
||||
year -= 1
|
||||
}
|
||||
|
||||
viewMonth.value = month
|
||||
viewYear.value = year
|
||||
}
|
||||
|
||||
function selectToday() {
|
||||
const today = todayJalali()
|
||||
viewYear.value = today.jy
|
||||
viewMonth.value = today.jm
|
||||
emit('update:modelValue', jalaliToISO(today.jy, today.jm, today.jd))
|
||||
closeCalendar()
|
||||
}
|
||||
|
||||
function isSelected(day: number) {
|
||||
if (!selectedJalali.value) return false
|
||||
const { jy, jm, jd } = selectedJalali.value
|
||||
return jy === viewYear.value && jm === viewMonth.value && jd === day
|
||||
}
|
||||
|
||||
function isToday(day: number) {
|
||||
const today = todayJalali()
|
||||
return today.jy === viewYear.value && today.jm === viewMonth.value && today.jd === day
|
||||
}
|
||||
|
||||
function onDocumentClick(event: MouseEvent) {
|
||||
if (!rootRef.value?.contains(event.target as Node)) {
|
||||
closeCalendar()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootRef" class="form-group persian-date-input">
|
||||
<label v-if="label" class="form-label">{{ label }}</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="input persian-date-input__trigger"
|
||||
:class="{ 'input-error': error, 'is-open': isOpen }"
|
||||
:disabled="disabled"
|
||||
@click.stop="toggleCalendar"
|
||||
>
|
||||
<span :class="{ 'persian-date-input__placeholder': !displayValue }">
|
||||
{{ displayValue || placeholder || 'انتخاب تاریخ' }}
|
||||
</span>
|
||||
<span class="persian-date-input__icon" aria-hidden="true">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M8 3v4M16 3v4M3 10h18" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div v-if="isOpen" class="persian-date-input__panel" @click.stop>
|
||||
<div class="persian-date-input__header">
|
||||
<button type="button" class="persian-date-input__nav" @click="changeMonth(-1)">‹</button>
|
||||
<strong class="persian-date-input__month">{{ monthLabel }}</strong>
|
||||
<button type="button" class="persian-date-input__nav" @click="changeMonth(1)">›</button>
|
||||
</div>
|
||||
|
||||
<div class="persian-date-input__weekdays">
|
||||
<span v-for="weekday in JALALI_WEEKDAY_NAMES" :key="weekday">{{ weekday }}</span>
|
||||
</div>
|
||||
|
||||
<div class="persian-date-input__grid">
|
||||
<template v-for="(day, index) in calendarDays" :key="index">
|
||||
<span v-if="day === null" class="persian-date-input__cell persian-date-input__cell--empty" />
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="persian-date-input__cell"
|
||||
:class="{
|
||||
'persian-date-input__cell--selected': isSelected(day),
|
||||
'persian-date-input__cell--today': isToday(day),
|
||||
}"
|
||||
@click="selectDay(day)"
|
||||
>
|
||||
{{ toPersianDigits(String(day)) }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button type="button" class="persian-date-input__today" @click="selectToday">امروز</button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="input-error-text">{{ error }}</p>
|
||||
<p v-else-if="hint" class="form-hint">{{ hint }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.persian-date-input {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
height: var(--btn-height);
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.9375rem;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.persian-date-input__trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.persian-date-input__trigger:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.persian-date-input__trigger.is-open,
|
||||
.persian-date-input__trigger:focus-visible {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.persian-date-input__trigger:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.persian-date-input__placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.persian-date-input__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.input-error {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.input-error-text {
|
||||
color: var(--color-danger);
|
||||
font-size: 0.8125rem;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.persian-date-input__panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 120;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.persian-date-input__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.persian-date-input__month {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.persian-date-input__nav {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fff;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.persian-date-input__weekdays,
|
||||
.persian-date-input__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.persian-date-input__weekdays {
|
||||
margin-bottom: 6px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.persian-date-input__cell {
|
||||
height: 34px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.persian-date-input__cell--empty {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.persian-date-input__cell--today {
|
||||
border: 1px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.persian-date-input__cell--selected {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.persian-date-input__today {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #f8fafc;
|
||||
color: var(--color-primary);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
modelValue?: string
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="search-field">
|
||||
<input
|
||||
class="search-field__input"
|
||||
type="search"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-field__input {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-family: inherit;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.5;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.search-field__input::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.search-field__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import BaseBadge from './BaseBadge.vue'
|
||||
import { ORDER_STATUS_LABELS, STATUS_BADGE_VARIANT } from '@/utils/constants'
|
||||
import type { OrderStatus } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
status: OrderStatus
|
||||
}>()
|
||||
|
||||
const label = computed(() => ORDER_STATUS_LABELS[props.status] ?? props.status)
|
||||
const variant = computed(() => STATUS_BADGE_VARIANT[props.status] ?? 'neutral')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBadge :variant="variant">{{ label }}</BaseBadge>
|
||||
</template>
|
||||
Reference in New Issue
Block a user