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:
s.esmaeili
2026-10-15 18:14:14 +03:30
parent ea86636027
commit be2c2ddf21
16 changed files with 1026 additions and 292 deletions
@@ -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

+106 -21
View File
@@ -1,23 +1,17 @@
<!-- <!--
Ported from legacy henza_pwa/src/components/DialogVant.vue. Island-style confirm dialog (two actions). Visibility still falls through
EXISTING ISSUE, ported as-is (not fixed): this component doesn't declare a via the root <van-dialog> receiving the parent's v-model:show.
`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.
--> -->
<template> <template>
<van-dialog> <van-dialog class="hg-dialog" :show-confirm-button="false" :show-cancel-button="false">
<div class="my-4"> <div class="hg-dialog__inner">
<div class="w-full flex justify-center"> <div class="hg-dialog__media">
<slot name="img">{{ text2 }}</slot> <slot name="img">{{ text2 }}</slot>
</div> </div>
<div class="w-full flex justify-center text-[18px] font-semibold"> <div class="hg-dialog__body">
<slot name="title">{{ text1 }}</slot> <slot name="title">{{ text1 }}</slot>
</div> </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> <slot name="button">{{ text3 }}</slot>
</div> </div>
</div> </div>
@@ -39,18 +33,109 @@ withDefaults(
</script> </script>
<style lang="less"> <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__confirm,
.van-dialog__cancel { .van-dialog__cancel,
.van-dialog.hg-dialog .van-dialog__footer {
display: none !important; display: none !important;
} }
.van-dialog__confirm .van-button__text {
color: @primary; .hg-dialog__inner {
font-weight: 600; display: flex;
font-size: 15px; 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-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> </style>
@@ -1,23 +1,17 @@
<!-- <!--
Ported from legacy henza_pwa/src/components/DialogVantOneButton.vue — the Island-style single-action dialog. Same fallthrough v-model:show pattern
single-action variant of DialogVant (one full-width button instead of a as DialogVant.vue.
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.
--> -->
<template> <template>
<van-dialog> <van-dialog class="hg-dialog" :show-confirm-button="false" :show-cancel-button="false">
<div class="my-4"> <div class="hg-dialog__inner">
<div class="w-full flex justify-center"> <div class="hg-dialog__media">
<slot name="img">{{ text2 }}</slot> <slot name="img">{{ text2 }}</slot>
</div> </div>
<div class="w-full flex justify-center text-[18px] font-semibold"> <div class="hg-dialog__body">
<slot name="title">{{ text1 }}</slot> <slot name="title">{{ text1 }}</slot>
</div> </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> <slot name="button">{{ text3 }}</slot>
</div> </div>
</div> </div>
@@ -39,18 +33,90 @@ withDefaults(
</script> </script>
<style lang="less"> <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__confirm,
.van-dialog__cancel { .van-dialog__cancel,
.van-dialog.hg-dialog .van-dialog__footer {
display: none !important; display: none !important;
} }
.van-dialog__confirm .van-button__text {
color: @primary; .hg-dialog__inner {
font-weight: 600; display: flex;
font-size: 15px; 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-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> </style>
@@ -8,7 +8,15 @@
order time, not stored on the product — printing one here would be invented. order time, not stored on the product — printing one here would be invented.
--> -->
<template> <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"> <div class="card__art">
<img v-lazy="image" :alt="title" /> <img v-lazy="image" :alt="title" />
<button type="button" class="card__fav" :class="{ 'is-on': favorites.isFavorite(id) }" @click.stop="favorites.toggle(id)"> <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 { useFavoritesStore } from '../../stores/favoritesStore'
import type { ProductSummary } from '../../api/client' 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 emit = defineEmits<{ (e: 'open', value: ProductSummary): void }>()
const favorites = useFavoritesStore() const favorites = useFavoritesStore()
@@ -68,20 +82,44 @@ const wageLabel = computed(() => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: @hairlineSoft; background: @hairlineSoft;
overflow: hidden;
} }
.card--wide .card__art { .card--wide .card__art {
aspect-ratio: 4 / 3; 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 { .card__art img {
width: 100%; width: 100%;
max-width: 200px; max-width: 220px;
height: 100%; height: 100%;
max-height: 200px; max-height: 100%;
object-fit: contain; object-fit: contain;
object-position: center; object-position: center;
display: block; display: block;
padding: 8px;
box-sizing: border-box;
} }
// Dark mode: product photos are shot on white, so they sit on a light mat // Dark mode: product photos are shot on white, so they sit on a light mat
@@ -25,7 +25,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeMount, ref, watch, type Ref } from 'vue' import { nextTick, onMounted, ref, watch, type Ref } from 'vue'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -101,11 +101,13 @@ watch(
}, },
) )
onBeforeMount(() => { onMounted(() => {
if (input.value && props.focus && props.shouldAutoFocus) { if (!props.focus || !props.shouldAutoFocus) return
input.value.focus() // Ref is only available after mount; nextTick covers paint on mobile keyboards.
input.value.select() nextTick(() => {
} input.value?.focus()
input.value?.select()
})
}) })
</script> </script>
@@ -519,6 +519,12 @@ onBeforeUnmount(() => {
overflow-x: hidden; overflow-x: hidden;
overscroll-behavior: contain; overscroll-behavior: contain;
-webkit-overflow-scrolling: touch; -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 // Backdrop blurs are re-computed on every frame their layer moves, and the page
@@ -103,20 +103,17 @@
</div> </div>
<!-- <!--
Variants. Each chip carries the variant's OWN gallery picture when the Compact variant chips: weight/title as pills. A tiny thumb only when
admin picked one, so two variants of the same weight (say plain vs. the variant has its OWN photo (not the product fallback) — otherwise
stone-set) are told apart at a glance instead of only by title — and every chip repeated the same large image and ate the fold.
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.
--> -->
<section v-if="variants && variants.length" class="pd__block"> <section v-if="variants && variants.length" class="pd__block pd__block--variants">
<h2 class="pd__blockTitle">انتخاب تنوع</h2> <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"> <div class="pd__variants">
<button <button
v-for="item in visibleVariants" v-for="item in visibleVariants"
@@ -126,20 +123,24 @@
:class="{ :class="{
'is-on': selectedVariant?.id === item.id, 'is-on': selectedVariant?.id === item.id,
'is-out': !isOrderable(item), 'is-out': !isOrderable(item),
'has-img': !!item.image?.url,
}" }"
:disabled="!isOrderable(item)" :disabled="!isOrderable(item)"
:title="stockChipLabel(item)"
@click="selectedVariant = item" @click="selectedVariant = item"
> >
<img <img
v-if="variantImage(item)" v-if="item.image?.url"
class="pd__variantImg" class="pd__variantImg"
:src="variantImage(item)" :src="item.image.url"
:alt="item.title" :alt="item.title"
/> />
<span class="pd__variantTitle">{{ item.title }}</span> <span class="pd__variantText">
<span class="pd__variantWeight">{{ Number(item.rashWeightGrams) }} گرم</span> <span class="pd__variantTitle">{{ item.title }}</span>
<span class="pd__variantWeight">{{ Number(item.rashWeightGrams) }} گرم</span>
</span>
<span class="pd__variantStock" :class="stockChipClass(item)"> <span class="pd__variantStock" :class="stockChipClass(item)">
{{ stockChipLabel(item) }} {{ stockChipShort(item) }}
</span> </span>
</button> </button>
</div> </div>
@@ -149,7 +150,7 @@
class="pd__variantsMore" class="pd__variantsMore"
@click="showAllVariants = !showAllVariants" @click="showAllVariants = !showAllVariants"
> >
{{ showAllVariants ? 'نمایش کمتر' : `نمایش همه (${variants.length})` }} {{ showAllVariants ? 'نمایش کمتر' : `+${variants.length - VARIANT_PREVIEW_COUNT} تنوع دیگر` }}
</button> </button>
</section> </section>
@@ -275,7 +276,7 @@
<DialogVant :timeOut="-1" v-model:show="loginNotify" :closeOnClickOverlay="true"> <DialogVant :timeOut="-1" v-model:show="loginNotify" :closeOnClickOverlay="true">
<template v-slot:img> <template v-slot:img>
<img :src="loginDialogImage" alt="" class="opacity-80" /> <img :src="loginDialogImage" alt="" />
</template> </template>
<template v-slot:title> <template v-slot:title>
<div class="p-8 text-justify"> <div class="p-8 text-justify">
@@ -305,7 +306,7 @@ import type { ProductSummary, ProductVariantSummary } from '../../api/client'
import { useFavoritesStore } from '../../stores/favoritesStore' import { useFavoritesStore } from '../../stores/favoritesStore'
import { useCartStore, type CartItem } from '../../stores/cartStore' import { useCartStore, type CartItem } from '../../stores/cartStore'
import { isUserLoggedIn } from '../../utils/checkAccess' 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 // `initial`: the card's own ProductSummary when opened from a list (see
// ProductMorph.vue) — lets the page render whole on its first frame. // 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) const is_exist = computed(() => productAdapter.value.is_exist.value)
// ── Variant chips ─────────────────────────────────────────────── // ── Variant chips ───────────────────────────────────────────────
// Two rows' worth is shown up front; the rest sit behind "نمایش همه" so a // Compact pills; extras behind a short toggle so weight lists don't dominate.
// product with 10+ weights does not push the description off the screen. const VARIANT_PREVIEW_COUNT = 8
const VARIANT_PREVIEW_COUNT = 6
const showAllVariants = ref(false) const showAllVariants = ref(false)
const visibleVariants = computed(() => const visibleVariants = computed(() =>
showAllVariants.value ? variants.value : variants.value.slice(0, VARIANT_PREVIEW_COUNT), showAllVariants.value ? variants.value : variants.value.slice(0, VARIANT_PREVIEW_COUNT),
@@ -372,18 +372,15 @@ const visibleVariants = computed(() =>
const isOrderable = (v: ProductVariantSummary) => isVariantOrderable(v) 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) => { const stockChipLabel = (v: ProductVariantSummary) => {
if ((v.qty ?? 0) > 0) return 'موجود' if ((v.qty ?? 0) > 0) return 'موجود'
return v.isPreOrder ? 'پیش‌سفارش' : 'ناموجود' 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) => { const stockChipClass = (v: ProductVariantSummary) => {
if ((v.qty ?? 0) > 0) return 'is-in' if ((v.qty ?? 0) > 0) return 'is-in'
return v.isPreOrder ? 'is-pre' : 'is-none' return v.isPreOrder ? 'is-pre' : 'is-none'
@@ -511,14 +508,22 @@ onBeforeUnmount(() => {
<style scoped lang="less"> <style scoped lang="less">
.pd { .pd {
position: relative; 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; 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; padding-bottom: 104px;
box-sizing: border-box;
} }
/* ── Gallery ─────────────────────────────────────────────── */ /* ── Gallery ─────────────────────────────────────────────── */
.pd__gallery { .pd__gallery {
position: relative; position: relative;
z-index: 0;
} }
.pd__stage { .pd__stage {
@@ -612,10 +617,15 @@ onBeforeUnmount(() => {
/* ── Sheet ───────────────────────────────────────────────── */ /* ── Sheet ───────────────────────────────────────────────── */
.pd__sheet { .pd__sheet {
position: relative; 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; margin-top: -20px;
border-radius: 22px 22px 0 0; border-radius: 22px 22px 0 0;
background: @canvas; background: @canvas;
padding: 20px 18px 8px; padding: 20px 18px 28px;
} }
.pd__head { .pd__head {
@@ -714,50 +724,77 @@ onBeforeUnmount(() => {
font-weight: 800; font-weight: 800;
color: @ink; 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 /* Compact wrapping pills — weight lists stay on-screen without card tiles. */
may have 10+ weights and off-screen chips are chips nobody taps. */
.pd__variants { .pd__variants {
display: grid; display: flex;
grid-template-columns: repeat(3, 1fr); flex-wrap: wrap;
gap: 8px; gap: 6px;
} }
.pd__variant { .pd__variant {
display: flex; display: inline-flex;
flex-direction: column; flex-direction: row;
gap: 3px;
padding: 8px;
border-radius: 14px;
cursor: pointer;
text-align: center;
align-items: center; align-items: center;
gap: 6px;
max-width: 100%;
padding: 6px 10px;
border-radius: 999px;
cursor: pointer;
text-align: start;
background: @canvas; background: @canvas;
border: 1px solid @hairline; 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 { .pd__variant.is-on {
background: @primary; background: @primary;
border-color: @primary; border-color: @primary;
} }
.pd__variant.has-img {
padding: 5px 10px 5px 5px;
border-radius: 14px;
}
.pd__variantImg { .pd__variantImg {
width: 100%; width: 40px;
aspect-ratio: 1; height: 40px;
flex: 0 0 40px;
object-fit: cover; object-fit: cover;
border-radius: 10px; border-radius: 10px;
margin-bottom: 2px; background: @hairlineSoft;
} }
/* Out of stock and not pre-orderable: still listed, so the customer can see .pd__variantText {
the weight exists, but visibly dimmed and not tappable. */ 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 { .pd__variant.is-out {
opacity: 0.45; opacity: 0.42;
cursor: not-allowed; cursor: not-allowed;
} }
.pd__variantStock { .pd__variantStock {
font-size: 9.5px; font-size: 9px;
font-weight: 700; font-weight: 700;
padding: 1px 6px; padding: 2px 5px;
border-radius: 999px; border-radius: 999px;
margin-top: 1px; flex-shrink: 0;
} }
.pd__variantStock.is-in { .pd__variantStock.is-in {
color: #1a7f3c; color: #1a7f3c;
@@ -776,31 +813,33 @@ onBeforeUnmount(() => {
background: rgba(255, 255, 255, 0.16); background: rgba(255, 255, 255, 0.16);
} }
.pd__variantsMore { .pd__variantsMore {
margin-top: 10px; margin-top: 8px;
width: 100%; padding: 0;
padding: 8px; border: 0;
border-radius: 12px;
border: 1px solid @hairline;
background: transparent; background: transparent;
color: @ink; color: @primary;
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
cursor: pointer; cursor: pointer;
} }
.pd__variantTitle { .pd__variantTitle {
font-size: 12.5px; font-size: 12px;
font-weight: 700; font-weight: 700;
color: @ink; color: @ink;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 9.5rem;
} }
.pd__variantWeight { .pd__variantWeight {
font-size: 10.5px; font-size: 10px;
color: @inkFaint; color: @inkFaint;
} }
.pd__variant.is-on .pd__variantTitle { .pd__variant.is-on .pd__variantTitle {
color: #fff; color: #fff;
} }
.pd__variant.is-on .pd__variantWeight { .pd__variant.is-on .pd__variantWeight {
color: rgba(255, 255, 255, 0.6); color: rgba(255, 255, 255, 0.7);
} }
.pd__specs { .pd__specs {
@@ -861,10 +900,12 @@ onBeforeUnmount(() => {
} }
.pd__trust { .pd__trust {
/* Sit at the bottom of the stretched sheet when content is short. */
margin-top: auto;
display: flex; display: flex;
gap: 18px; gap: 18px;
margin-top: 22px; margin-top: auto;
padding: 13px 0 4px; padding: 18px 0 0;
border-top: 1px solid @hairlineSoft; border-top: 1px solid @hairlineSoft;
} }
.pd__trust span { .pd__trust span {
@@ -10,15 +10,15 @@
--> -->
<template> <template>
<nav class="tabbar" v-show="nav.showNav"> <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 <div
class="tabbar__indicator" class="tabbar__indicator"
:style="{ transform: `translateX(${indicatorOffset}%)` }" :style="{ transform: `translateX(${indicatorOffset}%)` }"
aria-hidden="true" aria-hidden="true"
/> />
<router-link <router-link
v-for="item in nav.navBarRoutes" v-for="item in nav.visibleNavBarRoutes"
:key="item.index" :key="item.name"
:to="{ name: item.name }" :to="{ name: item.name }"
class="tabbar__item" class="tabbar__item"
:class="{ 'is-active': nav.activeLink === item.index }" :class="{ 'is-active': nav.activeLink === item.index }"
+7 -2
View File
@@ -35,7 +35,12 @@ export const router = createRouter({
{ path: '/cart', name: 'cart', component: () => import('../views/CartView.vue') }, { path: '/cart', name: 'cart', component: () => import('../views/CartView.vue') },
{ path: '/login', name: 'login', component: () => import('../views/user/LoginRegisterView.vue'), meta: { layout: 'FullPage' } }, { 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: '/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: '/orders', name: 'ordersList', component: () => import('../views/user/OrdersView.vue'), meta: { requiresAuth: true } },
{ {
path: '/transactions', path: '/transactions',
@@ -224,7 +229,7 @@ router.afterEach((to) => {
// is open (SearchView does, for its filter sheet), and this is what clears // 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 // 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. // 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) { if (route) {
nav.setActiveLink(route.index); nav.setActiveLink(route.index);
nav.setNavVisibility(true); nav.setNavVisibility(true);
+22 -8
View File
@@ -2,7 +2,9 @@
// class) to a real Pinia store, preserving the same state shape and setter // 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 // behavior. The route list itself was redesigned to a 5-tab bottom nav
// (Home, Search, Cart, Orders, Profile) — see components/ui/Navigation.vue. // (Home, Search, Cart, Orders, Profile) — see components/ui/Navigation.vue.
// Orders is only shown while signed in (see visibleNavBarRoutes).
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { useSessionStore } from './session'
interface NavItem { interface NavItem {
name: string name: string
@@ -12,8 +14,16 @@ interface NavItem {
index: number 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 { interface NavState {
navBarRoutes: NavItem[]
activeLink: number activeLink: number
showNav: boolean showNav: boolean
// legacy passes `undefined` to clear the transition (router/index.ts:270) // legacy passes `undefined` to clear the transition (router/index.ts:270)
@@ -34,19 +44,23 @@ interface NavState {
export const useNavStore = defineStore('nav', { export const useNavStore = defineStore('nav', {
state: (): NavState => ({ 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, activeLink: 1,
showNav: true, showNav: true,
pageTransition: 'right', pageTransition: 'right',
entryIndex: null, entryIndex: null,
}), }),
getters: { 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 * True when `router.back()` lands somewhere inside this app rather than on
* whatever the person was looking at before they opened it. * whatever the person was looking at before they opened it.
+2 -2
View File
@@ -128,7 +128,7 @@
<DialogVant :timeOut="-1" v-model:show="loginNotify" :closeOnClickOverlay="true"> <DialogVant :timeOut="-1" v-model:show="loginNotify" :closeOnClickOverlay="true">
<template v-slot:img> <template v-slot:img>
<img :src="loginDialogImage" alt="" class="opacity-80" /> <img :src="loginDialogImage" alt="" />
</template> </template>
<template v-slot:title> <template v-slot:title>
<div class="p-8 text-justify">برای ثبت سفارش ابتدا باید وارد حساب کاربری خود شوید.</div> <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 { Drawer, Form, FormItem, Textarea } from 'ant-design-vue'
import { Loading, PullRefresh } from 'vant' import { Loading, PullRefresh } from 'vant'
import dialogEmpty4 from '../assets/img/empty/dialogEmpty4.svg' 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 Icon from '../components/icon/Icon.vue'
import PageBar from '../components/ui/PageBar.vue' import PageBar from '../components/ui/PageBar.vue'
import Spinner from '../components/Spinner.vue' import Spinner from '../components/Spinner.vue'
+65 -13
View File
@@ -6,7 +6,7 @@
2. the live 18k rate strip (real: GET /pricing/latest) 2. the live 18k rate strip (real: GET /pricing/latest)
3. an editorial showcase hero built from real categories 3. an editorial showcase hero built from real categories
4. the category rail (real: derived from GET /catalog/products) 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 6. "تازه‌ها" — a horizontal rail
7. the account block (real: GET /customers/:id when signed in) 7. the account block (real: GET /customers/:id when signed in)
@@ -59,17 +59,33 @@
@select="onCategory" @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 <SectionHead
:title="`برگزیده ${brand.name}`" :title="`برگزیده ${brand.name}`"
subtitle="انتخاب‌شده از میان مجموعه‌های موجود" subtitle="انتخاب‌شده از میان مجموعه‌های موجود"
action-label="همه محصولات" action-label="همه محصولات"
@action="goSearch" @action="goSearch"
/> />
<ShowcaseCard :value="featured" wide badge="برگزیده" @open="openProduct" /> <div class="home__masonry">
<div class="home__masonryCol">
<div v-if="gridProducts.length" class="home__grid"> <ShowcaseCard
<ShowcaseCard v-for="item in gridProducts" :key="item.id" :value="item" @open="openProduct" /> 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> </div>
</section> </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. // and later returns to this tab do not replay it.
const { revealed, revealDelay, reveal } = useReveal('home') const { revealed, revealDelay, reveal } = useReveal('home')
const featured = computed<ProductSummary | null>(() => products.value[0] ?? null) /**
const gridProducts = computed(() => products.value.slice(1, 5)) * Featured masonry — two real columns with deliberately *different* height
const newestProducts = computed(() => products.value.slice(5, 13)) * 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 // The hero shows ONLY what the admin uploaded in the slider section
// (manage/Const.vue "اسلایدر موبایل"). It used to fall back to slides built // (manage/Const.vue "اسلایدر موبایل"). It used to fall back to slides built
@@ -337,11 +381,19 @@ onActivated(async () => {
margin-top: 28px; margin-top: 28px;
} }
.home__grid { /* Asymmetric masonry: two independent columns, unequal size rhythms. */
display: grid; .home__masonry {
grid-template-columns: repeat(2, minmax(0, 1fr)); display: flex;
align-items: flex-start;
gap: 11px;
}
.home__masonryCol {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
gap: 11px; gap: 11px;
margin-top: 11px;
} }
.home__reel { .home__reel {
+80 -34
View File
@@ -98,20 +98,22 @@
<!-- Nav grid --> <!-- Nav grid -->
<section class="dash__section reveal" :style="revealDelay(1)"> <section class="dash__section reveal" :style="revealDelay(1)">
<SectionHead title="دسترسی‌ها" subtitle="بخش‌های حساب شما" /> <div class="dash__access">
<div class="dash__grid">
<button <button
v-for="item in list" v-for="item in list"
:key="item.id" :key="item.id"
type="button" type="button"
class="dash__tile" class="dash__tile"
:class="{ 'is-disabled': item.disabled }" :class="[`is-${item.tone}`, { 'is-disabled': item.disabled }]"
@click="goToDetails(item.id)" @click="goToDetails(item.id)"
> >
<span class="dash__tileIcon" aria-hidden="true"> <span class="dash__tileIcon" aria-hidden="true">
<Icon :icon="item.icon" size="20" /> <Icon :icon="item.icon" size="21" />
</span> </span>
<span class="dash__tileTitle">{{ item.title }}</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> </button>
</div> </div>
</section> </section>
@@ -126,7 +128,7 @@
<DialogVant :timeOut="-1" v-model:show="data.notify" :closeOnClickOverlay="true"> <DialogVant :timeOut="-1" v-model:show="data.notify" :closeOnClickOverlay="true">
<template v-slot:img> <template v-slot:img>
<img :src="exitImage" alt="" class="opacity-70 mb-8" /> <img :src="exitImage" alt="" />
</template> </template>
<template v-slot:title>آیا می خواهید از برنامه خارج شوید؟</template> <template v-slot:title>آیا می خواهید از برنامه خارج شوید؟</template>
<template v-slot:button> <template v-slot:button>
@@ -137,7 +139,7 @@
<DialogVant :timeOut="-1" v-model:show="data.loginNotify" :closeOnClickOverlay="true"> <DialogVant :timeOut="-1" v-model:show="data.loginNotify" :closeOnClickOverlay="true">
<template v-slot:img> <template v-slot:img>
<img :src="loginDialogImage" alt="" class="opacity-80" /> <img :src="loginDialogImage" alt="" />
</template> </template>
<template v-slot:title> <template v-slot:title>
<div class="p-8 text-justify"> <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 SectionHead from '../../components/home/SectionHead.vue'
import RateStrip from '../../components/home/RateStrip.vue' import RateStrip from '../../components/home/RateStrip.vue'
import DialogVant from '../../components/DialogVant.vue' import DialogVant from '../../components/DialogVant.vue'
import exitImage from '../../assets/img/empty/dialogEmpty5.svg' import exitImage from '../../assets/img/empty/dialogLogout.svg'
import loginDialogImage from '../../assets/img/empty/dialogEmpty1.svg' import loginDialogImage from '../../assets/img/empty/dialogLogin.svg'
import { price } from '../../utils/filters' import { price } from '../../utils/filters'
import { useSessionStore } from '../../stores/session' import { useSessionStore } from '../../stores/session'
import { useReveal } from '../../composables/useReveal' import { useReveal } from '../../composables/useReveal'
@@ -191,14 +193,14 @@ const grams = computed(() =>
const data = reactive({ notify: false, loginNotify: false }) const data = reactive({ notify: false, loginNotify: false })
const BASE_TILES = [ const BASE_TILES = [
{ id: 0, icon: 'vuesax-linear:box', title: 'سفارشات', disabled: false }, { id: 0, icon: 'vuesax-linear:box', title: 'سفارشات', tone: 'forest', disabled: false },
{ id: 1, icon: 'vuesax-linear:receipt-text', title: 'اسناد', disabled: false }, { id: 1, icon: 'vuesax-linear:receipt-text', title: 'اسناد', tone: 'sand', disabled: false },
{ id: 2, icon: 'vuesax-linear:user', title: 'پروفایل', disabled: false }, { id: 2, icon: 'vuesax-linear:user', title: 'پروفایل', tone: 'wine', disabled: false },
{ id: 3, icon: 'vuesax-linear:heart', title: 'علاقمندی', 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, // 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 // 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. // 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. * server refuses both routes for a non-expert token regardless.
*/ */
const EXPERT_TILES = [ const EXPERT_TILES = [
{ id: 4, icon: 'vuesax-linear:box-tick', title: 'سفارش مشتریان', disabled: false }, { id: 4, icon: 'vuesax-linear:box-tick', title: 'سفارش مشتریان', tone: 'forest', disabled: false },
{ id: 5, icon: 'vuesax-linear:profile-2user', title: 'مشتریان من', disabled: false }, { id: 5, icon: 'vuesax-linear:profile-2user', title: 'مشتریان من', tone: 'sand', disabled: false },
] ]
// Appearance (theme + reduced motion) — always last, and open to everyone: // Appearance (theme + reduced motion) — always last, and open to everyone:
// device preferences, not account data. See goToDetails. // 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(() => [ const list = computed(() => [
...BASE_TILES, ...BASE_TILES,
@@ -522,47 +524,91 @@ onMounted(async () => {
margin-top: 26px; margin-top: 26px;
} }
.dash__grid { /* One island holding account shortcuts — denser, clearer than a flat 4-col grid. */
display: grid; .dash__access {
grid-template-columns: repeat(4, minmax(0, 1fr)); display: flex;
gap: 9px; 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 { .dash__tile {
display: flex; display: grid;
flex-direction: column; grid-template-columns: 44px 1fr 18px;
align-items: center; align-items: center;
gap: 8px; gap: 12px;
padding: 14px 6px; width: 100%;
border: 1px solid @hairline; margin: 0;
padding: 11px 12px;
border: 0;
border-radius: 16px; border-radius: 16px;
cursor: pointer; cursor: pointer;
background: @canvas; background: transparent;
font-family: inherit; font-family: inherit;
transition: transform 0.18s ease; text-align: start;
transition: background 0.15s ease, transform 0.15s ease;
} }
.dash__tile:active { .dash__tile:active {
transform: scale(0.97); transform: scale(0.99);
background: @canvasSoft;
} }
.dash__tile.is-disabled { .dash__tile.is-disabled {
opacity: 0.4; opacity: 0.4;
pointer-events: none;
} }
.dash__tileIcon { .dash__tileIcon {
width: 38px; width: 44px;
height: 38px; height: 44px;
border-radius: 12px; border-radius: 14px;
display: grid; display: grid;
place-items: center; 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; background: @hairlineSoft;
} }
.dash__tile.is-ink .dash__tileIcon {
color: @inkSoft;
background: alpha(@ink, 6%);
}
.dash__tileTitle { .dash__tileTitle {
font-size: 11px; font-size: 13.5px;
font-weight: 700; font-weight: 700;
color: @inkSoft; color: @ink;
letter-spacing: -0.01em;
}
.dash__tileArrow {
display: grid;
place-items: center;
color: @inkFaint;
opacity: 0.7;
} }
</style> </style>
+227 -52
View File
@@ -22,19 +22,34 @@
<PageBar title="ورود" back-to="home" /> <PageBar title="ورود" back-to="home" />
<container class="auth__body"> <container class="auth__body">
<section class="auth__intro"> <section class="auth__hero">
<div class="auth__mark" aria-hidden="true"> <img src="/img/brand-logo.png" :alt="brand.name" class="auth__logo" />
<Icon icon="vuesax-linear:security-user" size="28" />
</div>
<h2 class="auth__title">ورود به {{ brand.name }}</h2> <h2 class="auth__title">ورود به {{ brand.name }}</h2>
<p class="auth__sub"> <p class="auth__sub">
{{ body.step === 1 ? 'شماره موبایل خود را وارد کنید' : 'رمز یکبارمصرف را وارد کنید' }} {{ body.step === 1 ? 'با شماره موبایل وارد شوید؛ کد یکبارمصرف پیامک می‌شود.' : 'کد ۴ رقمی پیامک‌شده را وارد کنید.' }}
</p> </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>
<section class="auth__card"> <section class="auth__island">
<!-- Step 2: OTP --> <!-- Step 2: OTP -->
<template v-if="body.step === 2"> <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 <OtpInput
class="auth__otp" class="auth__otp"
input-classes="otp-input" input-classes="otp-input"
@@ -46,19 +61,27 @@
/> />
<p v-if="timer > 0" class="auth__timer"> <p v-if="timer > 0" class="auth__timer">
امکان دریافت مجدد رمز در {{ counterZeroShow(timer) }} ثانیه دیگر ارسال مجدد تا <strong>{{ counterZeroShow(timer) }}</strong> ثانیه
</p> </p>
<p v-else class="auth__timer">برای دریافت رمز عبور مجدد تلاش کنید</p> <p v-else class="auth__timer is-ready">می‌توانید کد را دوباره دریافت کنید</p>
</template> </template>
<Form :model="formModel" layout="vertical" @finish="sendPhone" class="auth__form"> <Form :model="formModel" layout="vertical" @finish="sendPhone" class="auth__form">
<FormItem <FormItem
v-show="body.step === 1" v-show="body.step === 1"
name="username" name="username"
label="تلفن همراه" label="شماره موبایل"
:rules="[required('تلفن همراه'), mobileRule('تلفن همراه')]" :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> </FormItem>
<button <button
@@ -67,14 +90,15 @@
:class="{ 'is-loading': body.loading }" :class="{ 'is-loading': body.loading }"
:disabled="body.loading || (timer > 0 && body.step === 2)" :disabled="body.loading || (timer > 0 && body.step === 2)"
> >
<span v-if="body.loading" class="auth__ctaSpin" aria-hidden="true" />
{{ btnText }} {{ btnText }}
</button> </button>
</Form> </Form>
</section> </section>
<p class="auth__switch"> <p class="auth__switch">
اگر ثبت نام نکرده‌اید حساب ندارید؟
<button type="button" @click="emit('register', true)">ثبت نام کنید</button> <button type="button" @click="emit('register', true)">ثبت‌نام فروشگاه</button>
</p> </p>
</container> </container>
</div> </div>
@@ -85,7 +109,6 @@ import { brand } from '../../brand'
import { computed, nextTick, reactive, ref } from 'vue' import { computed, nextTick, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { Form, FormItem, Input } from 'ant-design-vue' import { Form, FormItem, Input } from 'ant-design-vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue' import PageBar from '../../components/ui/PageBar.vue'
import { toast } from '../../composables/useToast' import { toast } from '../../composables/useToast'
import OtpInput from '../../components/input/OtpInput.vue' 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. * 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 counterZeroShow = (value: number) => (value < 10 ? '0' + value : value)
const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور' : 'دریافت مجدد رمز عبور')) const btnText = computed(() => (body.step === 1 ? 'دریافت کد ورود' : 'ارسال مجدد کد'))
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
.auth { .auth {
background: @page;
min-height: 100vh; min-height: 100vh;
background:
radial-gradient(120% 60% at 50% -10%, alpha(@primary, 8%), transparent 55%),
@page;
} }
.auth__body { .auth__body {
padding-top: 86px; padding-top: 86px;
padding-bottom: 90px; padding-bottom: 48px;
} }
.auth__intro { .auth__hero {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
text-align: center; text-align: center;
padding-bottom: 24px; padding: 4px 8px 22px;
} }
.auth__mark { .auth__logo {
width: 64px; width: 72px;
height: 64px; height: 72px;
border-radius: 50%; object-fit: contain;
display: grid; border-radius: 20px;
place-items: center; background: @canvas;
color: @wine; box-shadow:
background: @wineWash; 0 1px 0 rgba(15, 23, 42, 0.04),
0 12px 28px rgba(15, 23, 42, 0.08);
padding: 10px;
} }
.auth__title { .auth__title {
margin: 14px 0 0; margin: 16px 0 0;
font-size: 16px; font-size: 18px;
font-weight: 800; font-weight: 800;
color: @ink; color: @ink;
letter-spacing: -0.02em;
} }
.auth__sub { .auth__sub {
margin: 6px 0 0; margin: 8px 0 0;
max-width: 28rem;
font-size: 12.5px; font-size: 12.5px;
line-height: 1.7;
color: @inkFaint; color: @inkFaint;
} }
.auth__card { .auth__steps {
padding: 18px 16px; 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: 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; 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 { .auth__otp {
@@ -272,29 +390,69 @@ const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور'
justify-content: center; justify-content: center;
gap: 10px; gap: 10px;
direction: ltr; direction: ltr;
padding-bottom: 6px; padding-bottom: 4px;
} }
.auth__timer { .auth__timer {
margin: 12px 0 16px; margin: 14px 0 16px;
text-align: center; text-align: center;
font-size: 11.5px; font-size: 12px;
color: @inkMuted; 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) { .auth__form :deep(.ant-form-item-label > label) {
font-size: 12px; font-size: 12.5px;
color: @inkMuted; 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 { .auth__cta {
position: relative;
width: 100%; width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 50px;
padding: 13px 18px; padding: 13px 18px;
border: 0; border: 0;
cursor: pointer; cursor: pointer;
border-radius: 14px; border-radius: 16px;
font-family: inherit; font-family: inherit;
font-size: 13px; font-size: 14px;
font-weight: 700; font-weight: 700;
color: #fff; color: #fff;
background: @primary; background: @primary;
@@ -310,10 +468,25 @@ const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور'
cursor: default; 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 { .auth__switch {
margin: 20px 0 0; margin: 22px 0 0;
text-align: center; text-align: center;
font-size: 12px; font-size: 12.5px;
color: @inkMuted; color: @inkMuted;
} }
@@ -321,10 +494,10 @@ const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور'
border: 0; border: 0;
background: none; background: none;
cursor: pointer; cursor: pointer;
padding: 0 3px; padding: 0 4px;
font-family: inherit; font-family: inherit;
font-size: 12px; font-size: 12.5px;
font-weight: 700; font-weight: 800;
color: @wine; color: @wine;
} }
</style> </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 /* OtpInput renders its inputs with this class from a prop, so the rule cannot
be scoped to this component. */ be scoped to this component. */
.otp-input { .otp-input {
width: 48px; width: 52px;
height: 52px; height: 56px;
padding: 5px; padding: 5px;
margin: 0; margin: 0;
font-size: 19px; font-size: 20px;
font-weight: 700; font-weight: 800;
color: @ink; color: @ink;
background: @canvas; background: @canvasSoft;
border: 1px solid @hairline; border: 1px solid @hairline;
border-radius: 13px; border-radius: 16px;
text-align: center; text-align: center;
transition: border-color 0.18s ease; transition: border-color 0.18s ease, box-shadow 0.18s ease;
} }
.otp-input:focus { .otp-input:focus {
outline: none; outline: none;
border-color: @primary; border-color: @primary;
box-shadow: 0 0 0 3px alpha(@primary, 12%);
background: @canvas;
} }
.otp-input.error { .otp-input.error {
+184 -59
View File
@@ -18,53 +18,91 @@
--> -->
<template> <template>
<div class="auth"> <div class="auth">
<PageBar title="ثبت نام" back-to="home" /> <PageBar title="ثبت‌نام" back-to="home" />
<Spinner :spinning="profileData.loading" tip="در حال ثبت اطلاعات"> <Spinner :spinning="profileData.loading" tip="در حال ثبت اطلاعات">
<container class="auth__body"> <container class="auth__body">
<section class="auth__intro"> <section class="auth__hero">
<div class="auth__mark" aria-hidden="true"> <img src="/img/brand-logo.png" :alt="brand.name" class="auth__logo" />
<Icon icon="vuesax-linear:user-add" size="28" /> <h2 class="auth__title">ساخت حساب فروشگاه</h2>
</div> <p class="auth__sub">
<h2 class="auth__title">ساخت حساب کاربری</h2> پس از ثبت، کارشناسان {{ brand.name }} اطلاعات را بررسی می‌کنند و سپس می‌توانید وارد شوید.
<p class="auth__sub">اطلاعات فروشگاه خود را وارد کنید</p> </p>
</section> </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('نام')"> <FormItem name="fname" label="نام" :rules="required('نام')">
<Input v-model:value="formModel.fname" /> <Input v-model:value="formModel.fname" autocomplete="given-name" placeholder="مثلاً علی" />
</FormItem> </FormItem>
<FormItem name="lname" label="نام خانوادگی" :rules="required('نام خانوادگی')"> <FormItem name="lname" label="نام خانوادگی" :rules="required('نام خانوادگی')">
<Input v-model:value="formModel.lname" /> <Input v-model:value="formModel.lname" autocomplete="family-name" placeholder="مثلاً محمدی" />
</FormItem> </FormItem>
<FormItem <FormItem
name="mobile" name="mobile"
label="شماره موبایل" label="شماره موبایل"
:rules="[mobileRule('شماره موبایل'), required('شماره موبایل')]" :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> </FormItem>
</section>
<section class="auth__island">
<header class="auth__groupHead">
<h3>اطلاعات فروشگاه</h3>
<p>برای تأیید و ارسال سفارش‌ها لازم است</p>
</header>
<FormItem name="store_name" label="نام فروشگاه" :rules="required('نام فروشگاه')"> <FormItem name="store_name" label="نام فروشگاه" :rules="required('نام فروشگاه')">
<Input v-model:value="formModel.store_name" /> <Input v-model:value="formModel.store_name" placeholder="نام تابلو یا واحد صنفی" />
</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" />
</FormItem> </FormItem>
<button type="submit" class="auth__cta">ثبت اطلاعات</button> <FormItem name="phone" label="تلفن فروشگاه" :rules="[required('تلفن فروشگاه')]">
</Form> <Input
</section> 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"> <p class="auth__switch">
اگر حساب کاربری دارید قبلاً ثبت‌نام کرده‌اید؟
<button type="button" @click="emit('register', false)">وارد شوید</button> <button type="button" @click="emit('register', false)">ورود به حساب</button>
</p> </p>
</container> </container>
</Spinner> </Spinner>
@@ -74,8 +112,8 @@
<img :src="registerDelayImage" alt="" class="opacity-80" /> <img :src="registerDelayImage" alt="" class="opacity-80" />
</template> </template>
<template v-slot:title> <template v-slot:title>
<div class="p-5"> <div>
ثبت نام شما با موفقیت انجام شد. منتظر تایید اطلاعات ثبت نامی شما توسط کارشناسان بمانید. ثبت‌نام شما ثبت شد. پس از تأیید کارشناسان می‌توانید با همین شماره وارد شوید.
</div> </div>
</template> </template>
<template v-slot:button> <template v-slot:button>
@@ -86,11 +124,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { brand } from '../../brand'
import { reactive, ref } from 'vue' import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Form, FormItem, Input, Textarea } from 'ant-design-vue' import { Form, FormItem, Input, Textarea } from 'ant-design-vue'
import registerDelayImage from '../../assets/img/registerDelay.svg' import registerDelayImage from '../../assets/img/registerDelay.svg'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue' import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue' import Spinner from '../../components/Spinner.vue'
import DialogVant from '../../components/DialogVant.vue' import DialogVant from '../../components/DialogVant.vue'
@@ -149,81 +187,168 @@ function serverMessage(e: unknown): string | null {
<style scoped lang="less"> <style scoped lang="less">
.auth { .auth {
background: @page;
min-height: 100vh; min-height: 100vh;
background:
radial-gradient(120% 60% at 50% -10%, alpha(@primary, 8%), transparent 55%),
@page;
} }
.auth__body { .auth__body {
padding-top: 86px; padding-top: 86px;
padding-bottom: 90px; padding-bottom: 48px;
} }
.auth__intro { .auth__hero {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
text-align: center; text-align: center;
padding-bottom: 24px; padding: 4px 8px 20px;
} }
.auth__mark { .auth__logo {
width: 64px; width: 72px;
height: 64px; height: 72px;
border-radius: 50%; object-fit: contain;
display: grid; border-radius: 20px;
place-items: center; background: @canvas;
color: @wine; box-shadow:
background: @wineWash; 0 1px 0 rgba(15, 23, 42, 0.04),
0 12px 28px rgba(15, 23, 42, 0.08);
padding: 10px;
} }
.auth__title { .auth__title {
margin: 14px 0 0; margin: 16px 0 0;
font-size: 16px; font-size: 18px;
font-weight: 800; font-weight: 800;
color: @ink; color: @ink;
letter-spacing: -0.02em;
} }
.auth__sub { .auth__sub {
margin: 6px 0 0; margin: 8px 0 0;
max-width: 28rem;
font-size: 12.5px; font-size: 12.5px;
line-height: 1.7;
color: @inkFaint; color: @inkFaint;
} }
.auth__card { .auth__island {
padding: 18px 16px 20px; margin-bottom: 12px;
padding: 16px 16px 14px;
border-radius: 24px;
background: @canvas;
border: 1px solid @hairline; border: 1px solid @hairline;
border-radius: 20px; box-shadow:
background: @canvasSoft; 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) { .auth__form :deep(.ant-form-item-label > label) {
font-size: 12px; font-size: 12.5px;
color: @inkMuted; 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 { .auth__cta {
width: 100%; width: 100%;
margin-top: 4px; margin-top: 6px;
min-height: 50px;
padding: 13px 18px; padding: 13px 18px;
border: 0; border: 0;
cursor: pointer; cursor: pointer;
border-radius: 14px; border-radius: 16px;
font-family: inherit; font-family: inherit;
font-size: 13px; font-size: 14px;
font-weight: 700; font-weight: 700;
color: #fff; color: #fff;
background: @primary; background: @primary;
transition: transform 0.15s ease; transition: transform 0.15s ease, opacity 0.15s ease;
} }
.auth__cta:active { .auth__cta:active {
transform: scale(0.99); transform: scale(0.99);
} }
.auth__cta:disabled {
opacity: 0.5;
cursor: default;
}
.auth__switch { .auth__switch {
margin: 20px 0 0; margin: 20px 0 0;
text-align: center; text-align: center;
font-size: 12px; font-size: 12.5px;
color: @inkMuted; color: @inkMuted;
} }
@@ -231,10 +356,10 @@ function serverMessage(e: unknown): string | null {
border: 0; border: 0;
background: none; background: none;
cursor: pointer; cursor: pointer;
padding: 0 3px; padding: 0 4px;
font-family: inherit; font-family: inherit;
font-size: 12px; font-size: 12.5px;
font-weight: 700; font-weight: 800;
color: @wine; color: @wine;
} }
</style> </style>