Polish user-app auth, nav, and product UI for Hedena Gold.
Gate profile/orders on login, autofocus OTP, stretch product sheet white, and refresh dialog illustrations to the brand palette.
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
<svg width="200" height="160" viewBox="0 0 200 160" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Soft brand wash -->
|
||||
<ellipse cx="100" cy="118" rx="78" ry="22" fill="#23493c" fill-opacity="0.06"/>
|
||||
<circle cx="100" cy="78" r="58" fill="#23493c" fill-opacity="0.07"/>
|
||||
<circle cx="148" cy="42" r="10" fill="#7A1B33" fill-opacity="0.12"/>
|
||||
<circle cx="48" cy="52" r="6" fill="#8D6A46" fill-opacity="0.28"/>
|
||||
<circle cx="162" cy="88" r="4" fill="#23493c" fill-opacity="0.18"/>
|
||||
|
||||
<!-- Back card -->
|
||||
<g transform="translate(58 28) rotate(-8 42 52)">
|
||||
<rect x="0" y="0" width="84" height="104" rx="14" fill="#FAFAFA" stroke="#ECEDEF" stroke-width="1.5"/>
|
||||
<rect x="14" y="16" width="36" height="36" rx="18" fill="#FBF2F4"/>
|
||||
<circle cx="32" cy="30" r="10" fill="#A33C55" fill-opacity="0.55"/>
|
||||
<path d="M22 48c2.8-6 16.2-6 19 0" stroke="#A33C55" stroke-opacity="0.45" stroke-width="3" stroke-linecap="round"/>
|
||||
<rect x="14" y="64" width="40" height="7" rx="3.5" fill="#7A1B33" fill-opacity="0.2"/>
|
||||
<rect x="14" y="78" width="28" height="5" rx="2.5" fill="#ECEDEF"/>
|
||||
</g>
|
||||
|
||||
<!-- Front card -->
|
||||
<g transform="translate(78 36) rotate(6 42 52)">
|
||||
<rect x="0" y="0" width="84" height="104" rx="14" fill="#FFFFFF" stroke="#23493c" stroke-opacity="0.14" stroke-width="1.5"/>
|
||||
<rect x="0" y="0" width="84" height="104" rx="14" fill="#23493c" fill-opacity="0.03"/>
|
||||
<rect x="14" y="16" width="36" height="36" rx="18" fill="#E8EFEC"/>
|
||||
<circle cx="32" cy="30" r="10" fill="#23493c" fill-opacity="0.55"/>
|
||||
<path d="M22 48c2.8-6 16.2-6 19 0" stroke="#23493c" stroke-opacity="0.4" stroke-width="3" stroke-linecap="round"/>
|
||||
<rect x="14" y="64" width="44" height="7" rx="3.5" fill="#8D6A46"/>
|
||||
<rect x="14" y="78" width="30" height="5" rx="2.5" fill="#ECEDEF"/>
|
||||
</g>
|
||||
|
||||
<!-- Gold seal / coin accent -->
|
||||
<g transform="translate(138 96)">
|
||||
<circle cx="18" cy="18" r="18" fill="#8D6A46"/>
|
||||
<circle cx="18" cy="18" r="13.5" fill="#F4C57A" fill-opacity="0.35"/>
|
||||
<circle cx="18" cy="18" r="11" fill="none" stroke="#FAFAFA" stroke-opacity="0.55" stroke-width="1.25"/>
|
||||
<path d="M18 10.5v15M12.5 18h11" stroke="#FAFAFA" stroke-width="2" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,42 @@
|
||||
<svg width="200" height="150" viewBox="0 0 200 150" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Soft brand wash -->
|
||||
<ellipse cx="100" cy="122" rx="72" ry="18" fill="#23493c" fill-opacity="0.06"/>
|
||||
<circle cx="100" cy="72" r="54" fill="#23493c" fill-opacity="0.07"/>
|
||||
<circle cx="44" cy="40" r="5" fill="#8D6A46" fill-opacity="0.3"/>
|
||||
<circle cx="158" cy="48" r="7" fill="#7A1B33" fill-opacity="0.12"/>
|
||||
|
||||
<!-- Door frame -->
|
||||
<rect x="62" y="28" width="76" height="92" rx="10" fill="#FFFFFF" stroke="#23493c" stroke-opacity="0.16" stroke-width="1.75"/>
|
||||
<rect x="62" y="28" width="76" height="92" rx="10" fill="#23493c" fill-opacity="0.04"/>
|
||||
|
||||
<!-- Open door (hinged left, swung out) -->
|
||||
<path
|
||||
d="M72 36h28c1.1 0 2 .9 2 2v76c0 1.1-.9 2-2 2H72c-1.1 0-2-.9-2-2V38c0-1.1.9-2 2-2z"
|
||||
fill="#E8EFEC"
|
||||
/>
|
||||
<path
|
||||
d="M100 36l34 8v64l-34 8V36z"
|
||||
fill="#23493c"
|
||||
fill-opacity="0.12"
|
||||
stroke="#23493c"
|
||||
stroke-opacity="0.22"
|
||||
stroke-width="1.25"
|
||||
/>
|
||||
<path d="M118 72h8" stroke="#8D6A46" stroke-width="3" stroke-linecap="round"/>
|
||||
|
||||
<!-- Exit arrow -->
|
||||
<g transform="translate(128 58)">
|
||||
<circle cx="22" cy="22" r="20" fill="#7A1B33"/>
|
||||
<path
|
||||
d="M12 22h16M22 14l8 8-8 8"
|
||||
stroke="#FAFAFA"
|
||||
stroke-width="2.4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- Soft ground line -->
|
||||
<path d="M48 128h104" stroke="#ECEDEF" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -1,23 +1,17 @@
|
||||
<!--
|
||||
Ported from legacy henza_pwa/src/components/DialogVant.vue.
|
||||
EXISTING ISSUE, ported as-is (not fixed): this component doesn't declare a
|
||||
`show`/`modelValue` prop, and the internal `watch(() => props.modelValue, ...)`
|
||||
references a prop that was never declared (always undefined, so that watcher
|
||||
effectively never fires — its `showNotify`/timeout auto-hide logic is dead
|
||||
code). Actual visibility works anyway, because <van-dialog> is this
|
||||
component's sole root element and Vue's automatic attribute fallthrough
|
||||
passes the parent's `v-model:show` straight through to it. Preserved exactly.
|
||||
Island-style confirm dialog (two actions). Visibility still falls through
|
||||
via the root <van-dialog> receiving the parent's v-model:show.
|
||||
-->
|
||||
<template>
|
||||
<van-dialog>
|
||||
<div class="my-4">
|
||||
<div class="w-full flex justify-center">
|
||||
<van-dialog class="hg-dialog" :show-confirm-button="false" :show-cancel-button="false">
|
||||
<div class="hg-dialog__inner">
|
||||
<div class="hg-dialog__media">
|
||||
<slot name="img">{{ text2 }}</slot>
|
||||
</div>
|
||||
<div class="w-full flex justify-center text-[18px] font-semibold">
|
||||
<div class="hg-dialog__body">
|
||||
<slot name="title">{{ text1 }}</slot>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 border-t border-solid border-[color:var(--c-hairline)] pt-4 mx-2 mt-6 font-semibold text-[15px]">
|
||||
<div class="hg-dialog__actions hg-dialog__actions--pair">
|
||||
<slot name="button">{{ text3 }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
@@ -39,18 +33,109 @@ withDefaults(
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.van-dialog.hg-dialog {
|
||||
width: min(86vw, 340px) !important;
|
||||
max-width: 340px;
|
||||
overflow: hidden;
|
||||
border-radius: 28px !important;
|
||||
background: @canvas !important;
|
||||
box-shadow:
|
||||
0 18px 48px rgba(15, 23, 42, 0.18),
|
||||
0 2px 8px rgba(15, 23, 42, 0.06) !important;
|
||||
}
|
||||
|
||||
.van-dialog.hg-dialog .van-dialog__content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.van-dialog__confirm,
|
||||
.van-dialog__cancel {
|
||||
.van-dialog__cancel,
|
||||
.van-dialog.hg-dialog .van-dialog__footer {
|
||||
display: none !important;
|
||||
}
|
||||
.van-dialog__confirm .van-button__text {
|
||||
color: @primary;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
|
||||
.hg-dialog__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 8px 0 0;
|
||||
}
|
||||
.van-dialog__cancel .van-button__text {
|
||||
color: @inkMuted;
|
||||
|
||||
.hg-dialog__media {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 12px 20px 0;
|
||||
|
||||
img {
|
||||
max-width: 78%;
|
||||
max-height: 128px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.hg-dialog__body {
|
||||
padding: 10px 22px 6px;
|
||||
text-align: center;
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
line-height: 1.75;
|
||||
color: @ink;
|
||||
|
||||
/* Call sites often wrap copy in padded divs — flatten them here. */
|
||||
> div {
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
text-align: center !important;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.hg-dialog__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 14px 16px 16px;
|
||||
}
|
||||
|
||||
.hg-dialog__actions--pair > *,
|
||||
.hg-dialog__actions > * {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 46px;
|
||||
padding: 0 14px;
|
||||
border-radius: 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: transform 0.12s ease, opacity 0.12s ease;
|
||||
color: inherit;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.985);
|
||||
}
|
||||
}
|
||||
|
||||
/* Primary = first action (ورود / بله / …) */
|
||||
.hg-dialog__actions--pair > *:first-child {
|
||||
background: @primary !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* Secondary = dismiss */
|
||||
.hg-dialog__actions--pair > *:last-child:not(:only-child) {
|
||||
background: alpha(@ink, 5%) !important;
|
||||
color: @inkMuted !important;
|
||||
}
|
||||
|
||||
/* Single slotted action (e.g. متوجه شدم) */
|
||||
.hg-dialog__actions--pair > *:only-child {
|
||||
background: @primary !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
<!--
|
||||
Ported from legacy henza_pwa/src/components/DialogVantOneButton.vue — the
|
||||
single-action variant of DialogVant (one full-width button instead of a
|
||||
two-column grid). Used by App.vue's "new version available" dialog.
|
||||
|
||||
Same structure as DialogVant.vue: `show` is not a declared prop; the parent's
|
||||
v-model:show falls through onto <van-dialog>, which is this component's sole
|
||||
root element. Legacy's internal watch on an undeclared `modelValue` prop was
|
||||
dead code and is not carried over.
|
||||
Island-style single-action dialog. Same fallthrough v-model:show pattern
|
||||
as DialogVant.vue.
|
||||
-->
|
||||
<template>
|
||||
<van-dialog>
|
||||
<div class="my-4">
|
||||
<div class="w-full flex justify-center">
|
||||
<van-dialog class="hg-dialog" :show-confirm-button="false" :show-cancel-button="false">
|
||||
<div class="hg-dialog__inner">
|
||||
<div class="hg-dialog__media">
|
||||
<slot name="img">{{ text2 }}</slot>
|
||||
</div>
|
||||
<div class="w-full flex justify-center text-[18px] font-semibold">
|
||||
<div class="hg-dialog__body">
|
||||
<slot name="title">{{ text1 }}</slot>
|
||||
</div>
|
||||
<div class="border-t border-solid border-[color:var(--c-hairline)] pt-4 mx-2 mt-6 font-semibold text-[15px]">
|
||||
<div class="hg-dialog__actions">
|
||||
<slot name="button">{{ text3 }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
@@ -39,18 +33,90 @@ withDefaults(
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.van-dialog.hg-dialog {
|
||||
width: min(86vw, 340px) !important;
|
||||
max-width: 340px;
|
||||
overflow: hidden;
|
||||
border-radius: 28px !important;
|
||||
background: @canvas !important;
|
||||
box-shadow:
|
||||
0 18px 48px rgba(15, 23, 42, 0.18),
|
||||
0 2px 8px rgba(15, 23, 42, 0.06) !important;
|
||||
}
|
||||
|
||||
.van-dialog.hg-dialog .van-dialog__content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.van-dialog__confirm,
|
||||
.van-dialog__cancel {
|
||||
.van-dialog__cancel,
|
||||
.van-dialog.hg-dialog .van-dialog__footer {
|
||||
display: none !important;
|
||||
}
|
||||
.van-dialog__confirm .van-button__text {
|
||||
color: @primary;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
|
||||
.hg-dialog__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 8px 0 0;
|
||||
}
|
||||
.van-dialog__cancel .van-button__text {
|
||||
color: @inkMuted;
|
||||
|
||||
.hg-dialog__media {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 12px 20px 0;
|
||||
|
||||
img {
|
||||
max-width: 78%;
|
||||
max-height: 128px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.hg-dialog__body {
|
||||
padding: 10px 22px 6px;
|
||||
text-align: center;
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
line-height: 1.75;
|
||||
color: @ink;
|
||||
|
||||
> div {
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
text-align: center !important;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.hg-dialog__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 14px 16px 16px;
|
||||
|
||||
> * {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 46px;
|
||||
padding: 0 14px;
|
||||
border-radius: 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background: @primary !important;
|
||||
color: #fff !important;
|
||||
transition: transform 0.12s ease;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.985);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,7 +8,15 @@
|
||||
order time, not stored on the product — printing one here would be invented.
|
||||
-->
|
||||
<template>
|
||||
<article class="card" :class="{ 'card--wide': wide }" data-product-origin @click="emit('open', value)">
|
||||
<article
|
||||
class="card"
|
||||
:class="[
|
||||
{ 'card--wide': wide },
|
||||
size ? `card--${size}` : null,
|
||||
]"
|
||||
data-product-origin
|
||||
@click="emit('open', value)"
|
||||
>
|
||||
<div class="card__art">
|
||||
<img v-lazy="image" :alt="title" />
|
||||
<button type="button" class="card__fav" :class="{ 'is-on': favorites.isFavorite(id) }" @click.stop="favorites.toggle(id)">
|
||||
@@ -34,7 +42,13 @@ import { useProduct } from '../../composables/useProduct'
|
||||
import { useFavoritesStore } from '../../stores/favoritesStore'
|
||||
import type { ProductSummary } from '../../api/client'
|
||||
|
||||
const props = defineProps<{ value: ProductSummary; wide?: boolean; badge?: string }>()
|
||||
const props = defineProps<{
|
||||
value: ProductSummary
|
||||
wide?: boolean
|
||||
badge?: string
|
||||
/** Masonry height variant for the image well (asymmetric set). */
|
||||
size?: 'hero' | 'tall' | 'mid' | 'short' | 'squat' | 'square'
|
||||
}>()
|
||||
const emit = defineEmits<{ (e: 'open', value: ProductSummary): void }>()
|
||||
|
||||
const favorites = useFavoritesStore()
|
||||
@@ -68,20 +82,44 @@ const wageLabel = computed(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: @hairlineSoft;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card--wide .card__art {
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
.card--hero .card__art {
|
||||
aspect-ratio: 2 / 3;
|
||||
}
|
||||
|
||||
.card--tall .card__art {
|
||||
aspect-ratio: 3 / 4;
|
||||
}
|
||||
|
||||
.card--mid .card__art {
|
||||
aspect-ratio: 4 / 5;
|
||||
}
|
||||
|
||||
.card--square .card__art,
|
||||
.card--short .card__art {
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.card--squat .card__art {
|
||||
aspect-ratio: 6 / 5;
|
||||
}
|
||||
|
||||
.card__art img {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
max-width: 220px;
|
||||
height: 100%;
|
||||
max-height: 200px;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
display: block;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// Dark mode: product photos are shot on white, so they sit on a light mat
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeMount, ref, watch, type Ref } from 'vue'
|
||||
import { nextTick, onMounted, ref, watch, type Ref } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -101,11 +101,13 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeMount(() => {
|
||||
if (input.value && props.focus && props.shouldAutoFocus) {
|
||||
input.value.focus()
|
||||
input.value.select()
|
||||
}
|
||||
onMounted(() => {
|
||||
if (!props.focus || !props.shouldAutoFocus) return
|
||||
// Ref is only available after mount; nextTick covers paint on mobile keyboards.
|
||||
nextTick(() => {
|
||||
input.value?.focus()
|
||||
input.value?.select()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -519,6 +519,12 @@ onBeforeUnmount(() => {
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
/* Match product page so short content never flashes the soft-grey void. */
|
||||
background: @canvas;
|
||||
}
|
||||
|
||||
.pm__sheet :deep(.pd) {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
// Backdrop blurs are re-computed on every frame their layer moves, and the page
|
||||
|
||||
@@ -103,20 +103,17 @@
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Variants. Each chip carries the variant's OWN gallery picture when the
|
||||
admin picked one, so two variants of the same weight (say plain vs.
|
||||
stone-set) are told apart at a glance instead of only by title — and
|
||||
several weights of one design correctly show the same photo.
|
||||
|
||||
Shown from one variant up (not two): with a picture and a stock badge
|
||||
the chip is worth seeing even when there is only one choice.
|
||||
|
||||
The row wraps rather than scrolling sideways: past ~4 chips a
|
||||
horizontal strip hides the rest off-screen, and products here can have
|
||||
10+ weights. Beyond two rows the extra chips collapse behind a toggle.
|
||||
Compact variant chips: weight/title as pills. A tiny thumb only when
|
||||
the variant has its OWN photo (not the product fallback) — otherwise
|
||||
every chip repeated the same large image and ate the fold.
|
||||
-->
|
||||
<section v-if="variants && variants.length" class="pd__block">
|
||||
<h2 class="pd__blockTitle">انتخاب تنوع</h2>
|
||||
<section v-if="variants && variants.length" class="pd__block pd__block--variants">
|
||||
<div class="pd__blockHead">
|
||||
<h2 class="pd__blockTitle">انتخاب تنوع</h2>
|
||||
<span v-if="selectedVariant" class="pd__variantsHint">
|
||||
{{ Number(selectedVariant.rashWeightGrams) }} گرم
|
||||
</span>
|
||||
</div>
|
||||
<div class="pd__variants">
|
||||
<button
|
||||
v-for="item in visibleVariants"
|
||||
@@ -126,20 +123,24 @@
|
||||
:class="{
|
||||
'is-on': selectedVariant?.id === item.id,
|
||||
'is-out': !isOrderable(item),
|
||||
'has-img': !!item.image?.url,
|
||||
}"
|
||||
:disabled="!isOrderable(item)"
|
||||
:title="stockChipLabel(item)"
|
||||
@click="selectedVariant = item"
|
||||
>
|
||||
<img
|
||||
v-if="variantImage(item)"
|
||||
v-if="item.image?.url"
|
||||
class="pd__variantImg"
|
||||
:src="variantImage(item)"
|
||||
:src="item.image.url"
|
||||
:alt="item.title"
|
||||
/>
|
||||
<span class="pd__variantTitle">{{ item.title }}</span>
|
||||
<span class="pd__variantWeight">{{ Number(item.rashWeightGrams) }} گرم</span>
|
||||
<span class="pd__variantText">
|
||||
<span class="pd__variantTitle">{{ item.title }}</span>
|
||||
<span class="pd__variantWeight">{{ Number(item.rashWeightGrams) }} گرم</span>
|
||||
</span>
|
||||
<span class="pd__variantStock" :class="stockChipClass(item)">
|
||||
{{ stockChipLabel(item) }}
|
||||
{{ stockChipShort(item) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -149,7 +150,7 @@
|
||||
class="pd__variantsMore"
|
||||
@click="showAllVariants = !showAllVariants"
|
||||
>
|
||||
{{ showAllVariants ? 'نمایش کمتر' : `نمایش همه (${variants.length})` }}
|
||||
{{ showAllVariants ? 'نمایش کمتر' : `+${variants.length - VARIANT_PREVIEW_COUNT} تنوع دیگر` }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
@@ -275,7 +276,7 @@
|
||||
|
||||
<DialogVant :timeOut="-1" v-model:show="loginNotify" :closeOnClickOverlay="true">
|
||||
<template v-slot:img>
|
||||
<img :src="loginDialogImage" alt="" class="opacity-80" />
|
||||
<img :src="loginDialogImage" alt="" />
|
||||
</template>
|
||||
<template v-slot:title>
|
||||
<div class="p-8 text-justify">
|
||||
@@ -305,7 +306,7 @@ import type { ProductSummary, ProductVariantSummary } from '../../api/client'
|
||||
import { useFavoritesStore } from '../../stores/favoritesStore'
|
||||
import { useCartStore, type CartItem } from '../../stores/cartStore'
|
||||
import { isUserLoggedIn } from '../../utils/checkAccess'
|
||||
import loginDialogImage from '../../assets/img/empty/dialogEmpty1.svg'
|
||||
import loginDialogImage from '../../assets/img/empty/dialogLogin.svg'
|
||||
|
||||
// `initial`: the card's own ProductSummary when opened from a list (see
|
||||
// ProductMorph.vue) — lets the page render whole on its first frame.
|
||||
@@ -362,9 +363,8 @@ const isPreOrder = computed(() => productAdapter.value.isPreOrder.value)
|
||||
const is_exist = computed(() => productAdapter.value.is_exist.value)
|
||||
|
||||
// ── Variant chips ───────────────────────────────────────────────
|
||||
// Two rows' worth is shown up front; the rest sit behind "نمایش همه" so a
|
||||
// product with 10+ weights does not push the description off the screen.
|
||||
const VARIANT_PREVIEW_COUNT = 6
|
||||
// Compact pills; extras behind a short toggle so weight lists don't dominate.
|
||||
const VARIANT_PREVIEW_COUNT = 8
|
||||
const showAllVariants = ref(false)
|
||||
const visibleVariants = computed(() =>
|
||||
showAllVariants.value ? variants.value : variants.value.slice(0, VARIANT_PREVIEW_COUNT),
|
||||
@@ -372,18 +372,15 @@ const visibleVariants = computed(() =>
|
||||
|
||||
const isOrderable = (v: ProductVariantSummary) => isVariantOrderable(v)
|
||||
|
||||
/**
|
||||
* The chip picture: the variant's own image when the admin picked one, else the
|
||||
* product's main photo so chips never render as empty boxes. Variants sharing
|
||||
* one image (same design, several weights) intentionally show the same picture.
|
||||
*/
|
||||
const variantImage = (v: ProductVariantSummary) =>
|
||||
v.image?.url ?? productDetail.productRef.value?.images?.[0]?.url ?? ''
|
||||
|
||||
const stockChipLabel = (v: ProductVariantSummary) => {
|
||||
if ((v.qty ?? 0) > 0) return 'موجود'
|
||||
return v.isPreOrder ? 'پیشسفارش' : 'ناموجود'
|
||||
}
|
||||
/** One short glyph/word so the chip stays one line. */
|
||||
const stockChipShort = (v: ProductVariantSummary) => {
|
||||
if ((v.qty ?? 0) > 0) return 'موجود'
|
||||
return v.isPreOrder ? 'پیش' : '—'
|
||||
}
|
||||
const stockChipClass = (v: ProductVariantSummary) => {
|
||||
if ((v.qty ?? 0) > 0) return 'is-in'
|
||||
return v.isPreOrder ? 'is-pre' : 'is-none'
|
||||
@@ -511,14 +508,22 @@ onBeforeUnmount(() => {
|
||||
<style scoped lang="less">
|
||||
.pd {
|
||||
position: relative;
|
||||
/* Grid + min-height so the white sheet row (1fr) actually fills the viewport.
|
||||
Flex-grow alone fails here: parent height is indefinite (only min-height). */
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
min-height: 100vh;
|
||||
background: @canvasSoft;
|
||||
min-height: 100dvh;
|
||||
background: @canvas;
|
||||
/* Room for the fixed action bar — keep in sync with .pd__bar height. */
|
||||
padding-bottom: 104px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ── Gallery ─────────────────────────────────────────────── */
|
||||
.pd__gallery {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.pd__stage {
|
||||
@@ -612,10 +617,15 @@ onBeforeUnmount(() => {
|
||||
/* ── Sheet ───────────────────────────────────────────────── */
|
||||
.pd__sheet {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Fills the 1fr grid row down to the action-bar padding. */
|
||||
min-height: 0;
|
||||
margin-top: -20px;
|
||||
border-radius: 22px 22px 0 0;
|
||||
background: @canvas;
|
||||
padding: 20px 18px 8px;
|
||||
padding: 20px 18px 28px;
|
||||
}
|
||||
|
||||
.pd__head {
|
||||
@@ -714,50 +724,77 @@ onBeforeUnmount(() => {
|
||||
font-weight: 800;
|
||||
color: @ink;
|
||||
}
|
||||
.pd__block--variants .pd__blockTitle {
|
||||
margin: 0;
|
||||
}
|
||||
.pd__blockHead {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.pd__variantsHint {
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
color: @inkFaint;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Wraps instead of scrolling sideways — see the template comment: a product
|
||||
may have 10+ weights and off-screen chips are chips nobody taps. */
|
||||
/* Compact wrapping pills — weight lists stay on-screen without card tiles. */
|
||||
.pd__variants {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.pd__variant {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 8px;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
text-align: start;
|
||||
background: @canvas;
|
||||
border: 1px solid @hairline;
|
||||
transition: border-color 0.18s ease, background 0.18s ease;
|
||||
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.pd__variant.is-on {
|
||||
background: @primary;
|
||||
border-color: @primary;
|
||||
}
|
||||
.pd__variant.has-img {
|
||||
padding: 5px 10px 5px 5px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.pd__variantImg {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: 0 0 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 2px;
|
||||
background: @hairlineSoft;
|
||||
}
|
||||
/* Out of stock and not pre-orderable: still listed, so the customer can see
|
||||
the weight exists, but visibly dimmed and not tappable. */
|
||||
.pd__variantText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
/* Out of stock and not pre-orderable: still listed, dimmed, not tappable. */
|
||||
.pd__variant.is-out {
|
||||
opacity: 0.45;
|
||||
opacity: 0.42;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.pd__variantStock {
|
||||
font-size: 9.5px;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
padding: 1px 6px;
|
||||
padding: 2px 5px;
|
||||
border-radius: 999px;
|
||||
margin-top: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pd__variantStock.is-in {
|
||||
color: #1a7f3c;
|
||||
@@ -776,31 +813,33 @@ onBeforeUnmount(() => {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
.pd__variantsMore {
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid @hairline;
|
||||
margin-top: 8px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: @ink;
|
||||
color: @primary;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pd__variantTitle {
|
||||
font-size: 12.5px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: @ink;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 9.5rem;
|
||||
}
|
||||
.pd__variantWeight {
|
||||
font-size: 10.5px;
|
||||
font-size: 10px;
|
||||
color: @inkFaint;
|
||||
}
|
||||
.pd__variant.is-on .pd__variantTitle {
|
||||
color: #fff;
|
||||
}
|
||||
.pd__variant.is-on .pd__variantWeight {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.pd__specs {
|
||||
@@ -861,10 +900,12 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.pd__trust {
|
||||
/* Sit at the bottom of the stretched sheet when content is short. */
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
margin-top: 22px;
|
||||
padding: 13px 0 4px;
|
||||
margin-top: auto;
|
||||
padding: 18px 0 0;
|
||||
border-top: 1px solid @hairlineSoft;
|
||||
}
|
||||
.pd__trust span {
|
||||
|
||||
@@ -10,15 +10,15 @@
|
||||
-->
|
||||
<template>
|
||||
<nav class="tabbar" v-show="nav.showNav">
|
||||
<div class="tabbar__inner" :style="{ '--count': nav.navBarRoutes.length }">
|
||||
<div class="tabbar__inner" :style="{ '--count': nav.visibleNavBarRoutes.length }">
|
||||
<div
|
||||
class="tabbar__indicator"
|
||||
:style="{ transform: `translateX(${indicatorOffset}%)` }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<router-link
|
||||
v-for="item in nav.navBarRoutes"
|
||||
:key="item.index"
|
||||
v-for="item in nav.visibleNavBarRoutes"
|
||||
:key="item.name"
|
||||
:to="{ name: item.name }"
|
||||
class="tabbar__item"
|
||||
:class="{ 'is-active': nav.activeLink === item.index }"
|
||||
|
||||
@@ -35,7 +35,12 @@ export const router = createRouter({
|
||||
{ path: '/cart', name: 'cart', component: () => import('../views/CartView.vue') },
|
||||
{ path: '/login', name: 'login', component: () => import('../views/user/LoginRegisterView.vue'), meta: { layout: 'FullPage' } },
|
||||
{ path: '/auth/iam/callback', name: 'iamCallback', component: () => import('../views/user/IamCallback.vue'), meta: { layout: 'FullPage' } },
|
||||
{ path: '/user', name: 'userPage::hasSubRoute', component: () => import('../views/user/DashboardView.vue') },
|
||||
{
|
||||
path: '/user',
|
||||
name: 'userPage::hasSubRoute',
|
||||
component: () => import('../views/user/DashboardView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{ path: '/orders', name: 'ordersList', component: () => import('../views/user/OrdersView.vue'), meta: { requiresAuth: true } },
|
||||
{
|
||||
path: '/transactions',
|
||||
@@ -224,7 +229,7 @@ router.afterEach((to) => {
|
||||
// is open (SearchView does, for its filter sheet), and this is what clears
|
||||
// that override on the way out — the old `if (!nav.showNav)` guard made the
|
||||
// same write, just phrased as if something else might own the flag.
|
||||
const route = nav.navBarRoutes.find((x) => to.name === x.name);
|
||||
const route = nav.visibleNavBarRoutes.find((x) => to.name === x.name);
|
||||
if (route) {
|
||||
nav.setActiveLink(route.index);
|
||||
nav.setNavVisibility(true);
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
// class) to a real Pinia store, preserving the same state shape and setter
|
||||
// behavior. The route list itself was redesigned to a 5-tab bottom nav
|
||||
// (Home, Search, Cart, Orders, Profile) — see components/ui/Navigation.vue.
|
||||
// Orders is only shown while signed in (see visibleNavBarRoutes).
|
||||
import { defineStore } from 'pinia'
|
||||
import { useSessionStore } from './session'
|
||||
|
||||
interface NavItem {
|
||||
name: string
|
||||
@@ -12,8 +14,16 @@ interface NavItem {
|
||||
index: number
|
||||
}
|
||||
|
||||
/** Full tab set; indices are rewritten in `visibleNavBarRoutes` for the bar. */
|
||||
const ALL_NAV_ROUTES: Omit<NavItem, 'index'>[] = [
|
||||
{ name: 'home', path: '/home', icon: 'vuesax-linear:home-2', label: 'خانه' },
|
||||
{ name: 'search', path: '/search', icon: 'vuesax-linear:search-normal-1', label: 'جستجو' },
|
||||
{ name: 'cart', path: '/cart', icon: 'vuesax-linear:bag-happy', label: 'سبد خرید' },
|
||||
{ name: 'ordersList', path: '/orders', icon: 'vuesax-linear:receipt-text', label: 'سفارشات' },
|
||||
{ name: 'userPage::hasSubRoute', path: '/user', icon: 'vuesax-linear:user', label: 'پروفایل' },
|
||||
]
|
||||
|
||||
interface NavState {
|
||||
navBarRoutes: NavItem[]
|
||||
activeLink: number
|
||||
showNav: boolean
|
||||
// legacy passes `undefined` to clear the transition (router/index.ts:270)
|
||||
@@ -34,19 +44,23 @@ interface NavState {
|
||||
|
||||
export const useNavStore = defineStore('nav', {
|
||||
state: (): NavState => ({
|
||||
navBarRoutes: [
|
||||
{ name: 'home', path: '/home', icon: 'vuesax-linear:home-2', label: 'خانه', index: 1 },
|
||||
{ name: 'search', path: '/search', icon: 'vuesax-linear:search-normal-1', label: 'جستجو', index: 2 },
|
||||
{ name: 'cart', path: '/cart', icon: 'vuesax-linear:bag-happy', label: 'سبد خرید', index: 3 },
|
||||
{ name: 'ordersList', path: '/orders', icon: 'vuesax-linear:receipt-text', label: 'سفارشات', index: 4 },
|
||||
{ name: 'userPage::hasSubRoute', path: '/user', icon: 'vuesax-linear:user', label: 'پروفایل', index: 5 },
|
||||
],
|
||||
activeLink: 1,
|
||||
showNav: true,
|
||||
pageTransition: 'right',
|
||||
entryIndex: null,
|
||||
}),
|
||||
getters: {
|
||||
/**
|
||||
* Tabs shown in the bottom bar. سفارشات is omitted until the user is signed in.
|
||||
* Indices are 1-based and contiguous so the sliding indicator stays aligned.
|
||||
*/
|
||||
visibleNavBarRoutes(): NavItem[] {
|
||||
const session = useSessionStore()
|
||||
const routes = session.isLoggedIn
|
||||
? ALL_NAV_ROUTES
|
||||
: ALL_NAV_ROUTES.filter((r) => r.name !== 'ordersList')
|
||||
return routes.map((r, i) => ({ ...r, index: i + 1 }))
|
||||
},
|
||||
/**
|
||||
* True when `router.back()` lands somewhere inside this app rather than on
|
||||
* whatever the person was looking at before they opened it.
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
|
||||
<DialogVant :timeOut="-1" v-model:show="loginNotify" :closeOnClickOverlay="true">
|
||||
<template v-slot:img>
|
||||
<img :src="loginDialogImage" alt="" class="opacity-80" />
|
||||
<img :src="loginDialogImage" alt="" />
|
||||
</template>
|
||||
<template v-slot:title>
|
||||
<div class="p-8 text-justify">برای ثبت سفارش ابتدا باید وارد حساب کاربری خود شوید.</div>
|
||||
@@ -147,7 +147,7 @@ import { useRouter } from 'vue-router'
|
||||
import { Drawer, Form, FormItem, Textarea } from 'ant-design-vue'
|
||||
import { Loading, PullRefresh } from 'vant'
|
||||
import dialogEmpty4 from '../assets/img/empty/dialogEmpty4.svg'
|
||||
import loginDialogImage from '../assets/img/empty/dialogEmpty1.svg'
|
||||
import loginDialogImage from '../assets/img/empty/dialogLogin.svg'
|
||||
import Icon from '../components/icon/Icon.vue'
|
||||
import PageBar from '../components/ui/PageBar.vue'
|
||||
import Spinner from '../components/Spinner.vue'
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
2. the live 18k rate strip (real: GET /pricing/latest)
|
||||
3. an editorial showcase hero built from real categories
|
||||
4. the category rail (real: derived from GET /catalog/products)
|
||||
5. "برگزیده" — a wide feature card + a two-column grid
|
||||
5. "برگزیده" — masonry of product cards
|
||||
6. "تازهها" — a horizontal rail
|
||||
7. the account block (real: GET /customers/:id when signed in)
|
||||
|
||||
@@ -59,17 +59,33 @@
|
||||
@select="onCategory"
|
||||
/>
|
||||
|
||||
<section v-if="featured" class="home__section reveal" :style="revealDelay(3)">
|
||||
<section v-if="featuredProducts.length" class="home__section reveal" :style="revealDelay(3)">
|
||||
<SectionHead
|
||||
:title="`برگزیده ${brand.name}`"
|
||||
subtitle="انتخابشده از میان مجموعههای موجود"
|
||||
action-label="همه محصولات"
|
||||
@action="goSearch"
|
||||
/>
|
||||
<ShowcaseCard :value="featured" wide badge="برگزیده" @open="openProduct" />
|
||||
|
||||
<div v-if="gridProducts.length" class="home__grid">
|
||||
<ShowcaseCard v-for="item in gridProducts" :key="item.id" :value="item" @open="openProduct" />
|
||||
<div class="home__masonry">
|
||||
<div class="home__masonryCol">
|
||||
<ShowcaseCard
|
||||
v-for="(cell, i) in masonryCols.start"
|
||||
:key="cell.item.id"
|
||||
:value="cell.item"
|
||||
:badge="i === 0 ? 'برگزیده' : undefined"
|
||||
:size="cell.size"
|
||||
@open="openProduct"
|
||||
/>
|
||||
</div>
|
||||
<div class="home__masonryCol">
|
||||
<ShowcaseCard
|
||||
v-for="cell in masonryCols.end"
|
||||
:key="cell.item.id"
|
||||
:value="cell.item"
|
||||
:size="cell.size"
|
||||
@open="openProduct"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -160,9 +176,37 @@ const adminSlides = ref<AdminSlide[] | null>(reads.peek<AdminSlide[]>(KEYS.homeS
|
||||
// and later returns to this tab do not replay it.
|
||||
const { revealed, revealDelay, reveal } = useReveal('home')
|
||||
|
||||
const featured = computed<ProductSummary | null>(() => products.value[0] ?? null)
|
||||
const gridProducts = computed(() => products.value.slice(1, 5))
|
||||
const newestProducts = computed(() => products.value.slice(5, 13))
|
||||
/**
|
||||
* Featured masonry — two real columns with deliberately *different* height
|
||||
* rhythms (not a mirrored tall/short alternation), so the block reads
|
||||
* asymmetric. RTL puts `start` on the right.
|
||||
*/
|
||||
const featuredProducts = computed(() => products.value.slice(0, 9))
|
||||
const newestProducts = computed(() => products.value.slice(9, 17))
|
||||
|
||||
type MasonrySize = 'hero' | 'tall' | 'mid' | 'short' | 'squat'
|
||||
|
||||
/** Start column (visual right in RTL): opens tall, then irregular. */
|
||||
const START_SIZES: MasonrySize[] = ['hero', 'short', 'tall', 'mid', 'squat']
|
||||
/** End column: different sequence + often one fewer/taller cards. */
|
||||
const END_SIZES: MasonrySize[] = ['mid', 'hero', 'short', 'tall']
|
||||
|
||||
const masonryCols = computed(() => {
|
||||
const items = featuredProducts.value
|
||||
const start: { item: ProductSummary; size: MasonrySize }[] = []
|
||||
const end: { item: ProductSummary; size: MasonrySize }[] = []
|
||||
items.forEach((item, i) => {
|
||||
// Uneven split: 5 on start / 4 on end when we have 9 — keeps columns unequal.
|
||||
if (i % 2 === 0 && start.length < START_SIZES.length) {
|
||||
start.push({ item, size: START_SIZES[start.length] })
|
||||
} else if (end.length < END_SIZES.length) {
|
||||
end.push({ item, size: END_SIZES[end.length] })
|
||||
} else {
|
||||
start.push({ item, size: START_SIZES[start.length % START_SIZES.length] })
|
||||
}
|
||||
})
|
||||
return { start, end }
|
||||
})
|
||||
|
||||
// The hero shows ONLY what the admin uploaded in the slider section
|
||||
// (manage/Const.vue "اسلایدر موبایل"). It used to fall back to slides built
|
||||
@@ -337,11 +381,19 @@ onActivated(async () => {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.home__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
/* Asymmetric masonry: two independent columns, unequal size rhythms. */
|
||||
.home__masonry {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 11px;
|
||||
}
|
||||
|
||||
.home__masonryCol {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 11px;
|
||||
margin-top: 11px;
|
||||
}
|
||||
|
||||
.home__reel {
|
||||
|
||||
@@ -98,20 +98,22 @@
|
||||
|
||||
<!-- Nav grid -->
|
||||
<section class="dash__section reveal" :style="revealDelay(1)">
|
||||
<SectionHead title="دسترسیها" subtitle="بخشهای حساب شما" />
|
||||
<div class="dash__grid">
|
||||
<div class="dash__access">
|
||||
<button
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="dash__tile"
|
||||
:class="{ 'is-disabled': item.disabled }"
|
||||
:class="[`is-${item.tone}`, { 'is-disabled': item.disabled }]"
|
||||
@click="goToDetails(item.id)"
|
||||
>
|
||||
<span class="dash__tileIcon" aria-hidden="true">
|
||||
<Icon :icon="item.icon" size="20" />
|
||||
<Icon :icon="item.icon" size="21" />
|
||||
</span>
|
||||
<span class="dash__tileTitle">{{ item.title }}</span>
|
||||
<span class="dash__tileArrow" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:arrow-left-2" size="14" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -126,7 +128,7 @@
|
||||
|
||||
<DialogVant :timeOut="-1" v-model:show="data.notify" :closeOnClickOverlay="true">
|
||||
<template v-slot:img>
|
||||
<img :src="exitImage" alt="" class="opacity-70 mb-8" />
|
||||
<img :src="exitImage" alt="" />
|
||||
</template>
|
||||
<template v-slot:title>آیا می خواهید از برنامه خارج شوید؟</template>
|
||||
<template v-slot:button>
|
||||
@@ -137,7 +139,7 @@
|
||||
|
||||
<DialogVant :timeOut="-1" v-model:show="data.loginNotify" :closeOnClickOverlay="true">
|
||||
<template v-slot:img>
|
||||
<img :src="loginDialogImage" alt="" class="opacity-80" />
|
||||
<img :src="loginDialogImage" alt="" />
|
||||
</template>
|
||||
<template v-slot:title>
|
||||
<div class="p-8 text-justify">
|
||||
@@ -162,8 +164,8 @@ import Icon from '../../components/icon/Icon.vue'
|
||||
import SectionHead from '../../components/home/SectionHead.vue'
|
||||
import RateStrip from '../../components/home/RateStrip.vue'
|
||||
import DialogVant from '../../components/DialogVant.vue'
|
||||
import exitImage from '../../assets/img/empty/dialogEmpty5.svg'
|
||||
import loginDialogImage from '../../assets/img/empty/dialogEmpty1.svg'
|
||||
import exitImage from '../../assets/img/empty/dialogLogout.svg'
|
||||
import loginDialogImage from '../../assets/img/empty/dialogLogin.svg'
|
||||
import { price } from '../../utils/filters'
|
||||
import { useSessionStore } from '../../stores/session'
|
||||
import { useReveal } from '../../composables/useReveal'
|
||||
@@ -191,14 +193,14 @@ const grams = computed(() =>
|
||||
const data = reactive({ notify: false, loginNotify: false })
|
||||
|
||||
const BASE_TILES = [
|
||||
{ id: 0, icon: 'vuesax-linear:box', title: 'سفارشات', disabled: false },
|
||||
{ id: 1, icon: 'vuesax-linear:receipt-text', title: 'اسناد', disabled: false },
|
||||
{ id: 2, icon: 'vuesax-linear:user', title: 'پروفایل', disabled: false },
|
||||
{ id: 3, icon: 'vuesax-linear:heart', title: 'علاقمندی', disabled: false },
|
||||
{ id: 0, icon: 'vuesax-linear:box', title: 'سفارشات', tone: 'forest', disabled: false },
|
||||
{ id: 1, icon: 'vuesax-linear:receipt-text', title: 'اسناد', tone: 'sand', disabled: false },
|
||||
{ id: 2, icon: 'vuesax-linear:user', title: 'پروفایل', tone: 'wine', disabled: false },
|
||||
{ id: 3, icon: 'vuesax-linear:heart', title: 'علاقمندی', tone: 'rose', disabled: false },
|
||||
// D24 — the account statement. The reference put it on the dashboard too,
|
||||
// gated on hasMCode() (Dashboard.vue:179, :449). The target has no mcode, so
|
||||
// every signed-in customer sees it and the server scopes it to their token.
|
||||
{ id: 6, icon: 'vuesax-linear:chart-2', title: 'گزارش', disabled: false },
|
||||
{ id: 6, icon: 'vuesax-linear:chart-2', title: 'گزارش', tone: 'slate', disabled: false },
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -208,13 +210,13 @@ const BASE_TILES = [
|
||||
* server refuses both routes for a non-expert token regardless.
|
||||
*/
|
||||
const EXPERT_TILES = [
|
||||
{ id: 4, icon: 'vuesax-linear:box-tick', title: 'سفارش مشتریان', disabled: false },
|
||||
{ id: 5, icon: 'vuesax-linear:profile-2user', title: 'مشتریان من', disabled: false },
|
||||
{ id: 4, icon: 'vuesax-linear:box-tick', title: 'سفارش مشتریان', tone: 'forest', disabled: false },
|
||||
{ id: 5, icon: 'vuesax-linear:profile-2user', title: 'مشتریان من', tone: 'sand', disabled: false },
|
||||
]
|
||||
|
||||
// Appearance (theme + reduced motion) — always last, and open to everyone:
|
||||
// device preferences, not account data. See goToDetails.
|
||||
const APPEARANCE_TILE = { id: 7, icon: 'vuesax-linear:brush', title: 'ظاهر', disabled: false }
|
||||
const APPEARANCE_TILE = { id: 7, icon: 'vuesax-linear:brush', title: 'ظاهر', tone: 'ink', disabled: false }
|
||||
|
||||
const list = computed(() => [
|
||||
...BASE_TILES,
|
||||
@@ -522,47 +524,91 @@ onMounted(async () => {
|
||||
margin-top: 26px;
|
||||
}
|
||||
|
||||
.dash__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 9px;
|
||||
/* One island holding account shortcuts — denser, clearer than a flat 4-col grid. */
|
||||
.dash__access {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
padding: 6px;
|
||||
border-radius: 22px;
|
||||
background: @canvas;
|
||||
box-shadow:
|
||||
0 1px 0 rgba(15, 23, 42, 0.04),
|
||||
0 10px 28px rgba(15, 23, 42, 0.06);
|
||||
border: 1px solid @hairline;
|
||||
}
|
||||
|
||||
.dash__tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr 18px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 6px;
|
||||
border: 1px solid @hairline;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 11px 12px;
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
background: @canvas;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
transition: transform 0.18s ease;
|
||||
text-align: start;
|
||||
transition: background 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.dash__tile:active {
|
||||
transform: scale(0.97);
|
||||
transform: scale(0.99);
|
||||
background: @canvasSoft;
|
||||
}
|
||||
|
||||
.dash__tile.is-disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dash__tileIcon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: @inkSoft;
|
||||
}
|
||||
|
||||
.dash__tile.is-forest .dash__tileIcon {
|
||||
color: @primary;
|
||||
background: alpha(@primary, 10%);
|
||||
}
|
||||
.dash__tile.is-sand .dash__tileIcon {
|
||||
color: @secondary;
|
||||
background: alpha(@secondary, 12%);
|
||||
}
|
||||
.dash__tile.is-wine .dash__tileIcon {
|
||||
color: @wine;
|
||||
background: @wineWash;
|
||||
}
|
||||
.dash__tile.is-rose .dash__tileIcon {
|
||||
color: @wineSoft;
|
||||
background: alpha(@wineSoft, 12%);
|
||||
}
|
||||
.dash__tile.is-slate .dash__tileIcon {
|
||||
color: @inkMuted;
|
||||
background: @hairlineSoft;
|
||||
}
|
||||
.dash__tile.is-ink .dash__tileIcon {
|
||||
color: @inkSoft;
|
||||
background: alpha(@ink, 6%);
|
||||
}
|
||||
|
||||
.dash__tileTitle {
|
||||
font-size: 11px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
color: @inkSoft;
|
||||
color: @ink;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.dash__tileArrow {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: @inkFaint;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -22,19 +22,34 @@
|
||||
<PageBar title="ورود" back-to="home" />
|
||||
|
||||
<container class="auth__body">
|
||||
<section class="auth__intro">
|
||||
<div class="auth__mark" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:security-user" size="28" />
|
||||
</div>
|
||||
<section class="auth__hero">
|
||||
<img src="/img/brand-logo.png" :alt="brand.name" class="auth__logo" />
|
||||
<h2 class="auth__title">ورود به {{ brand.name }}</h2>
|
||||
<p class="auth__sub">
|
||||
{{ body.step === 1 ? 'شماره موبایل خود را وارد کنید' : 'رمز یکبارمصرف را وارد کنید' }}
|
||||
{{ body.step === 1 ? 'با شماره موبایل وارد شوید؛ کد یکبارمصرف پیامک میشود.' : 'کد ۴ رقمی پیامکشده را وارد کنید.' }}
|
||||
</p>
|
||||
|
||||
<ol class="auth__steps" aria-label="مراحل ورود">
|
||||
<li class="auth__step" :class="{ 'is-on': body.step === 1, 'is-done': body.step > 1 }">
|
||||
<span class="auth__stepNum">۱</span>
|
||||
<span>شماره</span>
|
||||
</li>
|
||||
<li class="auth__stepLine" aria-hidden="true" />
|
||||
<li class="auth__step" :class="{ 'is-on': body.step === 2 }">
|
||||
<span class="auth__stepNum">۲</span>
|
||||
<span>کد تأیید</span>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="auth__card">
|
||||
<section class="auth__island">
|
||||
<!-- Step 2: OTP -->
|
||||
<template v-if="body.step === 2">
|
||||
<div class="auth__phoneChip">
|
||||
<span>{{ formModel.username }}</span>
|
||||
<button type="button" class="auth__editPhone" @click="backToPhone">ویرایش</button>
|
||||
</div>
|
||||
|
||||
<OtpInput
|
||||
class="auth__otp"
|
||||
input-classes="otp-input"
|
||||
@@ -46,19 +61,27 @@
|
||||
/>
|
||||
|
||||
<p v-if="timer > 0" class="auth__timer">
|
||||
امکان دریافت مجدد رمز در {{ counterZeroShow(timer) }} ثانیه دیگر
|
||||
ارسال مجدد تا <strong>{{ counterZeroShow(timer) }}</strong> ثانیه
|
||||
</p>
|
||||
<p v-else class="auth__timer">برای دریافت رمز عبور مجدد تلاش کنید</p>
|
||||
<p v-else class="auth__timer is-ready">میتوانید کد را دوباره دریافت کنید</p>
|
||||
</template>
|
||||
|
||||
<Form :model="formModel" layout="vertical" @finish="sendPhone" class="auth__form">
|
||||
<FormItem
|
||||
v-show="body.step === 1"
|
||||
name="username"
|
||||
label="تلفن همراه"
|
||||
:rules="[required('تلفن همراه'), mobileRule('تلفن همراه')]"
|
||||
label="شماره موبایل"
|
||||
:rules="[required('شماره موبایل'), mobileRule('شماره موبایل')]"
|
||||
>
|
||||
<Input type="number" v-model:value="formModel.username" />
|
||||
<Input
|
||||
type="tel"
|
||||
inputmode="numeric"
|
||||
dir="ltr"
|
||||
placeholder="09123456789"
|
||||
autocomplete="tel"
|
||||
v-model:value="formModel.username"
|
||||
class="auth__phoneInput"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<button
|
||||
@@ -67,14 +90,15 @@
|
||||
:class="{ 'is-loading': body.loading }"
|
||||
:disabled="body.loading || (timer > 0 && body.step === 2)"
|
||||
>
|
||||
<span v-if="body.loading" class="auth__ctaSpin" aria-hidden="true" />
|
||||
{{ btnText }}
|
||||
</button>
|
||||
</Form>
|
||||
</section>
|
||||
|
||||
<p class="auth__switch">
|
||||
اگر ثبت نام نکردهاید
|
||||
<button type="button" @click="emit('register', true)">ثبت نام کنید</button>
|
||||
حساب ندارید؟
|
||||
<button type="button" @click="emit('register', true)">ثبتنام فروشگاه</button>
|
||||
</p>
|
||||
</container>
|
||||
</div>
|
||||
@@ -85,7 +109,6 @@ import { brand } from '../../brand'
|
||||
import { computed, nextTick, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Form, FormItem, Input } from 'ant-design-vue'
|
||||
import Icon from '../../components/icon/Icon.vue'
|
||||
import PageBar from '../../components/ui/PageBar.vue'
|
||||
import { toast } from '../../composables/useToast'
|
||||
import OtpInput from '../../components/input/OtpInput.vue'
|
||||
@@ -117,6 +140,12 @@ const countDownTimer = () => {
|
||||
}
|
||||
}
|
||||
|
||||
function backToPhone() {
|
||||
body.step = 1
|
||||
body.defaultFocus = false
|
||||
timer.value = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1 — ask the server to send an SMS code.
|
||||
*
|
||||
@@ -215,56 +244,145 @@ function extractServerMessage(e: Error): string | null {
|
||||
|
||||
const counterZeroShow = (value: number) => (value < 10 ? '0' + value : value)
|
||||
|
||||
const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور' : 'دریافت مجدد رمز عبور'))
|
||||
const btnText = computed(() => (body.step === 1 ? 'دریافت کد ورود' : 'ارسال مجدد کد'))
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.auth {
|
||||
background: @page;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(120% 60% at 50% -10%, alpha(@primary, 8%), transparent 55%),
|
||||
@page;
|
||||
}
|
||||
|
||||
.auth__body {
|
||||
padding-top: 86px;
|
||||
padding-bottom: 90px;
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
|
||||
.auth__intro {
|
||||
.auth__hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding-bottom: 24px;
|
||||
padding: 4px 8px 22px;
|
||||
}
|
||||
|
||||
.auth__mark {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: @wine;
|
||||
background: @wineWash;
|
||||
.auth__logo {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: contain;
|
||||
border-radius: 20px;
|
||||
background: @canvas;
|
||||
box-shadow:
|
||||
0 1px 0 rgba(15, 23, 42, 0.04),
|
||||
0 12px 28px rgba(15, 23, 42, 0.08);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.auth__title {
|
||||
margin: 14px 0 0;
|
||||
font-size: 16px;
|
||||
margin: 16px 0 0;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: @ink;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.auth__sub {
|
||||
margin: 6px 0 0;
|
||||
margin: 8px 0 0;
|
||||
max-width: 28rem;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.7;
|
||||
color: @inkFaint;
|
||||
}
|
||||
|
||||
.auth__card {
|
||||
padding: 18px 16px;
|
||||
.auth__steps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin: 18px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.auth__step {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
color: @inkFaint;
|
||||
}
|
||||
|
||||
.auth__step.is-on,
|
||||
.auth__step.is-done {
|
||||
color: @ink;
|
||||
}
|
||||
|
||||
.auth__stepNum {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 11px;
|
||||
background: @hairlineSoft;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.auth__step.is-on .auth__stepNum {
|
||||
background: @primary;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.auth__step.is-done .auth__stepNum {
|
||||
background: alpha(@primary, 14%);
|
||||
color: @primary;
|
||||
}
|
||||
|
||||
.auth__stepLine {
|
||||
width: 28px;
|
||||
height: 1px;
|
||||
background: @hairline;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.auth__island {
|
||||
padding: 20px 16px 18px;
|
||||
border-radius: 24px;
|
||||
background: @canvas;
|
||||
border: 1px solid @hairline;
|
||||
border-radius: 20px;
|
||||
box-shadow:
|
||||
0 1px 0 rgba(15, 23, 42, 0.04),
|
||||
0 14px 36px rgba(15, 23, 42, 0.07);
|
||||
}
|
||||
|
||||
.auth__phoneChip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
background: @canvasSoft;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: @ink;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.auth__editPhone {
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: @wine;
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.auth__otp {
|
||||
@@ -272,29 +390,69 @@ const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور'
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
direction: ltr;
|
||||
padding-bottom: 6px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.auth__timer {
|
||||
margin: 12px 0 16px;
|
||||
margin: 14px 0 16px;
|
||||
text-align: center;
|
||||
font-size: 11.5px;
|
||||
font-size: 12px;
|
||||
color: @inkMuted;
|
||||
|
||||
strong {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: @ink;
|
||||
}
|
||||
|
||||
&.is-ready {
|
||||
color: @primary;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.auth__form :deep(.ant-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.auth__form :deep(.ant-form-item-label > label) {
|
||||
font-size: 12px;
|
||||
color: @inkMuted;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: @inkSoft;
|
||||
}
|
||||
|
||||
.auth__form :deep(.ant-input) {
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
border-color: @hairline;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
background: @canvasSoft;
|
||||
}
|
||||
|
||||
.auth__form :deep(.ant-input:focus),
|
||||
.auth__form :deep(.ant-input-focused) {
|
||||
border-color: @primary;
|
||||
box-shadow: 0 0 0 3px alpha(@primary, 12%);
|
||||
}
|
||||
|
||||
.auth__phoneInput {
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.auth__cta {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 50px;
|
||||
padding: 13px 18px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 14px;
|
||||
border-radius: 16px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: @primary;
|
||||
@@ -310,10 +468,25 @@ const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور'
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.auth__ctaSpin {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.35);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: auth-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes auth-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.auth__switch {
|
||||
margin: 20px 0 0;
|
||||
margin: 22px 0 0;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-size: 12.5px;
|
||||
color: @inkMuted;
|
||||
}
|
||||
|
||||
@@ -321,10 +494,10 @@ const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور'
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0 3px;
|
||||
padding: 0 4px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-size: 12.5px;
|
||||
font-weight: 800;
|
||||
color: @wine;
|
||||
}
|
||||
</style>
|
||||
@@ -333,23 +506,25 @@ const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور'
|
||||
/* OtpInput renders its inputs with this class from a prop, so the rule cannot
|
||||
be scoped to this component. */
|
||||
.otp-input {
|
||||
width: 48px;
|
||||
height: 52px;
|
||||
width: 52px;
|
||||
height: 56px;
|
||||
padding: 5px;
|
||||
margin: 0;
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: @ink;
|
||||
background: @canvas;
|
||||
background: @canvasSoft;
|
||||
border: 1px solid @hairline;
|
||||
border-radius: 13px;
|
||||
border-radius: 16px;
|
||||
text-align: center;
|
||||
transition: border-color 0.18s ease;
|
||||
transition: border-color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.otp-input:focus {
|
||||
outline: none;
|
||||
border-color: @primary;
|
||||
box-shadow: 0 0 0 3px alpha(@primary, 12%);
|
||||
background: @canvas;
|
||||
}
|
||||
|
||||
.otp-input.error {
|
||||
|
||||
@@ -18,53 +18,91 @@
|
||||
-->
|
||||
<template>
|
||||
<div class="auth">
|
||||
<PageBar title="ثبت نام" back-to="home" />
|
||||
<PageBar title="ثبتنام" back-to="home" />
|
||||
|
||||
<Spinner :spinning="profileData.loading" tip="در حال ثبت اطلاعات">
|
||||
<container class="auth__body">
|
||||
<section class="auth__intro">
|
||||
<div class="auth__mark" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:user-add" size="28" />
|
||||
</div>
|
||||
<h2 class="auth__title">ساخت حساب کاربری</h2>
|
||||
<p class="auth__sub">اطلاعات فروشگاه خود را وارد کنید</p>
|
||||
<section class="auth__hero">
|
||||
<img src="/img/brand-logo.png" :alt="brand.name" class="auth__logo" />
|
||||
<h2 class="auth__title">ساخت حساب فروشگاه</h2>
|
||||
<p class="auth__sub">
|
||||
پس از ثبت، کارشناسان {{ brand.name }} اطلاعات را بررسی میکنند و سپس میتوانید وارد شوید.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="auth__card">
|
||||
<Form :model="formModel" layout="vertical" @finish="register" class="auth__form">
|
||||
<Form :model="formModel" layout="vertical" @finish="register" class="auth__form">
|
||||
<section class="auth__island">
|
||||
<header class="auth__groupHead">
|
||||
<h3>اطلاعات شخصی</h3>
|
||||
<p>نام و شمارهٔ تماس مالک فروشگاه</p>
|
||||
</header>
|
||||
|
||||
<FormItem name="fname" label="نام" :rules="required('نام')">
|
||||
<Input v-model:value="formModel.fname" />
|
||||
<Input v-model:value="formModel.fname" autocomplete="given-name" placeholder="مثلاً علی" />
|
||||
</FormItem>
|
||||
<FormItem name="lname" label="نام خانوادگی" :rules="required('نام خانوادگی')">
|
||||
<Input v-model:value="formModel.lname" />
|
||||
<Input v-model:value="formModel.lname" autocomplete="family-name" placeholder="مثلاً محمدی" />
|
||||
</FormItem>
|
||||
<FormItem
|
||||
name="mobile"
|
||||
label="شماره موبایل"
|
||||
:rules="[mobileRule('شماره موبایل'), required('شماره موبایل')]"
|
||||
class="auth__itemLast"
|
||||
>
|
||||
<Input v-model:value="formModel.mobile" type="number" />
|
||||
<Input
|
||||
v-model:value="formModel.mobile"
|
||||
type="tel"
|
||||
inputmode="numeric"
|
||||
dir="ltr"
|
||||
placeholder="09123456789"
|
||||
autocomplete="tel"
|
||||
class="auth__ltr"
|
||||
/>
|
||||
</FormItem>
|
||||
</section>
|
||||
|
||||
<section class="auth__island">
|
||||
<header class="auth__groupHead">
|
||||
<h3>اطلاعات فروشگاه</h3>
|
||||
<p>برای تأیید و ارسال سفارشها لازم است</p>
|
||||
</header>
|
||||
|
||||
<FormItem name="store_name" label="نام فروشگاه" :rules="required('نام فروشگاه')">
|
||||
<Input v-model:value="formModel.store_name" />
|
||||
</FormItem>
|
||||
<FormItem name="phone" label="تلفن فروشگاه" :rules="[required('تلفن فروشگاه')]">
|
||||
<Input v-model:value="formModel.phone" type="number" />
|
||||
</FormItem>
|
||||
<FormItem name="address" label="آدرس پستی" :rules="required('آدرس')">
|
||||
<Textarea v-model:value="formModel.address" :rows="3" />
|
||||
</FormItem>
|
||||
<FormItem name="city" label="شهر" :rules="required('شهر')">
|
||||
<Input v-model:value="formModel.city" />
|
||||
<Input v-model:value="formModel.store_name" placeholder="نام تابلو یا واحد صنفی" />
|
||||
</FormItem>
|
||||
|
||||
<button type="submit" class="auth__cta">ثبت اطلاعات</button>
|
||||
</Form>
|
||||
</section>
|
||||
<FormItem name="phone" label="تلفن فروشگاه" :rules="[required('تلفن فروشگاه')]">
|
||||
<Input
|
||||
v-model:value="formModel.phone"
|
||||
type="tel"
|
||||
inputmode="numeric"
|
||||
dir="ltr"
|
||||
placeholder="021xxxxxxx"
|
||||
class="auth__ltr"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem name="city" label="شهر" :rules="required('شهر')">
|
||||
<Input v-model:value="formModel.city" placeholder="مثلاً تهران" />
|
||||
</FormItem>
|
||||
|
||||
<FormItem name="address" label="آدرس پستی" :rules="required('آدرس')" class="auth__itemLast">
|
||||
<Textarea
|
||||
v-model:value="formModel.address"
|
||||
:rows="3"
|
||||
placeholder="آدرس کامل برای هماهنگی ارسال"
|
||||
/>
|
||||
</FormItem>
|
||||
</section>
|
||||
|
||||
<button type="submit" class="auth__cta" :disabled="profileData.loading">
|
||||
ثبت درخواست عضویت
|
||||
</button>
|
||||
</Form>
|
||||
|
||||
<p class="auth__switch">
|
||||
اگر حساب کاربری دارید
|
||||
<button type="button" @click="emit('register', false)">وارد شوید</button>
|
||||
قبلاً ثبتنام کردهاید؟
|
||||
<button type="button" @click="emit('register', false)">ورود به حساب</button>
|
||||
</p>
|
||||
</container>
|
||||
</Spinner>
|
||||
@@ -74,8 +112,8 @@
|
||||
<img :src="registerDelayImage" alt="" class="opacity-80" />
|
||||
</template>
|
||||
<template v-slot:title>
|
||||
<div class="p-5">
|
||||
ثبت نام شما با موفقیت انجام شد. منتظر تایید اطلاعات ثبت نامی شما توسط کارشناسان بمانید.
|
||||
<div>
|
||||
ثبتنام شما ثبت شد. پس از تأیید کارشناسان میتوانید با همین شماره وارد شوید.
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:button>
|
||||
@@ -86,11 +124,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { brand } from '../../brand'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Form, FormItem, Input, Textarea } from 'ant-design-vue'
|
||||
import registerDelayImage from '../../assets/img/registerDelay.svg'
|
||||
import Icon from '../../components/icon/Icon.vue'
|
||||
import PageBar from '../../components/ui/PageBar.vue'
|
||||
import Spinner from '../../components/Spinner.vue'
|
||||
import DialogVant from '../../components/DialogVant.vue'
|
||||
@@ -149,81 +187,168 @@ function serverMessage(e: unknown): string | null {
|
||||
|
||||
<style scoped lang="less">
|
||||
.auth {
|
||||
background: @page;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(120% 60% at 50% -10%, alpha(@primary, 8%), transparent 55%),
|
||||
@page;
|
||||
}
|
||||
|
||||
.auth__body {
|
||||
padding-top: 86px;
|
||||
padding-bottom: 90px;
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
|
||||
.auth__intro {
|
||||
.auth__hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding-bottom: 24px;
|
||||
padding: 4px 8px 20px;
|
||||
}
|
||||
|
||||
.auth__mark {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: @wine;
|
||||
background: @wineWash;
|
||||
.auth__logo {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: contain;
|
||||
border-radius: 20px;
|
||||
background: @canvas;
|
||||
box-shadow:
|
||||
0 1px 0 rgba(15, 23, 42, 0.04),
|
||||
0 12px 28px rgba(15, 23, 42, 0.08);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.auth__title {
|
||||
margin: 14px 0 0;
|
||||
font-size: 16px;
|
||||
margin: 16px 0 0;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: @ink;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.auth__sub {
|
||||
margin: 6px 0 0;
|
||||
margin: 8px 0 0;
|
||||
max-width: 28rem;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.7;
|
||||
color: @inkFaint;
|
||||
}
|
||||
|
||||
.auth__card {
|
||||
padding: 18px 16px 20px;
|
||||
.auth__island {
|
||||
margin-bottom: 12px;
|
||||
padding: 16px 16px 14px;
|
||||
border-radius: 24px;
|
||||
background: @canvas;
|
||||
border: 1px solid @hairline;
|
||||
border-radius: 20px;
|
||||
background: @canvasSoft;
|
||||
box-shadow:
|
||||
0 1px 0 rgba(15, 23, 42, 0.04),
|
||||
0 14px 36px rgba(15, 23, 42, 0.07);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.auth__groupHead {
|
||||
margin-bottom: 12px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: @ink;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 11.5px;
|
||||
color: @inkFaint;
|
||||
}
|
||||
}
|
||||
|
||||
.auth__form :deep(.ant-form-item) {
|
||||
margin-bottom: 12px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.auth__form :deep(.auth__itemLast.ant-form-item),
|
||||
.auth__form :deep(.ant-form-item.auth__itemLast) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.auth__form :deep(.ant-form-item-control),
|
||||
.auth__form :deep(.ant-form-item-control-input),
|
||||
.auth__form :deep(.ant-form-item-control-input-content) {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.auth__form :deep(.ant-form-item-label > label) {
|
||||
font-size: 12px;
|
||||
color: @inkMuted;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: @inkSoft;
|
||||
}
|
||||
|
||||
.auth__form :deep(.ant-input),
|
||||
.auth__form :deep(textarea.ant-input) {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
border-radius: 14px;
|
||||
border-color: @hairline;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
background: @canvasSoft;
|
||||
}
|
||||
|
||||
.auth__form :deep(.ant-input) {
|
||||
height: 46px;
|
||||
}
|
||||
|
||||
.auth__form :deep(textarea.ant-input) {
|
||||
min-height: 92px;
|
||||
padding-top: 12px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.auth__form :deep(.ant-input:focus),
|
||||
.auth__form :deep(.ant-input-focused),
|
||||
.auth__form :deep(textarea.ant-input:focus) {
|
||||
border-color: @primary;
|
||||
box-shadow: 0 0 0 3px alpha(@primary, 12%);
|
||||
}
|
||||
|
||||
.auth__ltr {
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.auth__cta {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
margin-top: 6px;
|
||||
min-height: 50px;
|
||||
padding: 13px 18px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 14px;
|
||||
border-radius: 16px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: @primary;
|
||||
transition: transform 0.15s ease;
|
||||
transition: transform 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.auth__cta:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.auth__cta:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.auth__switch {
|
||||
margin: 20px 0 0;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-size: 12.5px;
|
||||
color: @inkMuted;
|
||||
}
|
||||
|
||||
@@ -231,10 +356,10 @@ function serverMessage(e: unknown): string | null {
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0 3px;
|
||||
padding: 0 4px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-size: 12.5px;
|
||||
font-weight: 800;
|
||||
color: @wine;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user