feat(brand): rebrand to Hedena Gold and bring main user-app chrome

Rename the brand, ship Hedena logos, persist login OTP text in the SMS list, and port home/nav/auth/theme polish from main while keeping expert customers intact.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mahmood
2026-10-27 16:40:54 +03:30
co-authored by Cursor
parent bc881a0a8a
commit c438ab2159
94 changed files with 3385 additions and 1396 deletions
+1 -1
View File
@@ -168,7 +168,7 @@ ORDER_ADMIN_MOBILES=
# ---------------------------------------------------------------------------
# Brand
# ---------------------------------------------------------------------------
# The name at the top of every SMS («تهران تاج\n…») and the site link in the
# The name at the top of every SMS («هدناگلد\n…») and the site link in the
# welcome SMS. Blank = the defaults in apps/api/src/common/brand.ts. The
# frontends take their brand from the repo-root brand.ts instead; the full
# rebrand checklist is docs/BRANDING.md.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 871 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 14 KiB

+2 -2
View File
@@ -389,8 +389,8 @@ async function main() {
await prisma.brand.updateMany({ where: { id: 'brand-henza' }, data: { id: 'brand-montzar' } });
const brandMain = await prisma.brand.upsert({
where: { id: 'brand-montzar' },
update: { title: 'تهران تاج طلا' },
create: { id: 'brand-montzar', title: 'تهران تاج طلا' },
update: { title: 'هدناگلد' },
create: { id: 'brand-montzar', title: 'هدناگلد' },
});
const brandArtisan = await prisma.brand.upsert({
where: { id: 'brand-artisan' },
+1 -1
View File
@@ -8,7 +8,7 @@ import { envOr } from './env';
* docs/BRANDING.md for the full list.
*/
export function brandName(): string {
return envOr('BRAND_NAME', 'تهران تاج');
return envOr('BRAND_NAME', 'هدناگلد');
}
export function brandSiteUrl(): string {
@@ -11,7 +11,7 @@ import {
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { AdminRole, CustomerKind, MessageAudience, Prisma, VerificationStep } from '@prisma/client';
import { AdminRole, CustomerKind, Prisma, VerificationStep } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
import {
@@ -887,10 +887,9 @@ export class CustomerAuthService {
`رمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن ${brandName()} ${code} می باشد\n` +
`aMo6iFyUQaY`;
// persist:false — the real text carries the live code, and the admin SMS
// grid lists and searches `body` (SEC-11). The redacted audit row below
// takes its place.
const result = await this.sms.send(mobile, smsText, { persist: false });
// Persist the real OTP body so the admin SMS list can show login codes
// (owner request: operators need to read them; risk accepted).
const result = await this.sms.send(mobile, smsText);
// Only outside production, and only when no gateway is configured, so a
// deployment can never print live codes into its logs.
@@ -928,22 +927,6 @@ export class CustomerAuthService {
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// An audit row, NOT the message body.
//
// SmsMessage.body is listed and full-text searchable on the admin panel's
// SMS grid (admin-misc.service.ts:136 allows `body`, :135 searches it), so
// persisting the real text would show every live OTP to any operator
// holding the `sms` rid — and the whole point of hashing codeHash is that
// the code exists nowhere in readable form. The code is redacted here.
await this.prisma.smsMessage.create({
data: {
audience: MessageAudience.SINGLE_NUMBER,
recipients: mobile,
body: 'کد یکبار مصرف ورود (متن پیام ثبت نمی‌شود)',
sentCount: 1,
},
});
}
}
@@ -334,7 +334,7 @@ describe('OrdersService.createOrder — order SMS', () => {
await service.createOrder(dto, 'c1', { placedBy: 'customer' });
await settle();
const toCustomer = sent.find((s) => s.to === '09120000001');
expect(toCustomer?.body).toContain('به ثبت رسید. تهران تاج');
expect(toCustomer?.body).toContain('به ثبت رسید. هدناگلد');
expect(toCustomer?.body).not.toContain('معلق');
});
+3 -3
View File
@@ -310,18 +310,18 @@ describe('SmsService.send', () => {
/**
* The OTP text keeps the reference's shape (ApiMobileController.php:47) with
* the brand تهران تاج — the first line and the trailing token are the Android SMS
* the brand هدناگلد — the first line and the trailing token are the Android SMS
* Retriever hash, and autofill breaks if either changes.
*/
describe('OTP message template', () => {
const build = (code: string) =>
`<#> code is ${code}\n` +
`رمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن تهران تاج ${code} می باشد\n` +
`رمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن هدناگلد ${code} می باشد\n` +
`aMo6iFyUQaY`;
it('matches the expected text byte for byte', () => {
expect(build('1234')).toBe(
'<#> code is 1234\nرمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن تهران تاج 1234 می باشد\naMo6iFyUQaY',
'<#> code is 1234\nرمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن هدناگلد 1234 می باشد\naMo6iFyUQaY',
);
});
+3 -5
View File
@@ -14,12 +14,10 @@ import { isPrivateIpv4, resolveRelayHost } from './drivers/relay.driver';
*
* Reference behaviour kept for every driver:
* * the message is trim()ed before sending (`Sms.php:77`)
* * EVERY message is persisted, via `Sms::set()` (`:80`)
* * EVERY message is persisted, via `Sms::set()` (`:80`) — including OTP
* login texts (owner request: show codes in the admin SMS list)
* * the response body is returned and the callers ignore it
*
* The OTP message body is NOT persisted in readable form — see
* CustomerAuthService.deliver() and SEC-11.
*
* Failure handling matches the reference: `Sms::Send` is called inline and its
* result is discarded, so a failed send never aborted the order, the document
* approval or the customer creation that triggered it. `send()` therefore
@@ -31,7 +29,7 @@ export type { SmsSendResult } from './drivers';
export interface SmsSendOptions {
/**
* Persist the message body. Default true, matching `Sms::set()`.
* OTP delivery passes false and writes its own redacted row (SEC-11).
* Pass false only when the caller writes its own audit row.
*/
persist?: boolean;
audience?: MessageAudience;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 87 KiB

+3 -2
View File
@@ -17,9 +17,10 @@
<script>
(function () {
try {
var s = JSON.parse(localStorage.getItem('gold-bank:appearance') || '{}');
var s = JSON.parse(localStorage.getItem('hedena-gold:appearance') || '{}');
var mq = function (q) { return window.matchMedia && matchMedia(q).matches; };
var dark = s.theme === 'dark' || ((s.theme || 'system') === 'system' && mq('(prefers-color-scheme: dark)'));
// Default is light when nothing is saved (matches appearanceStore).
var dark = s.theme === 'dark' || (s.theme === 'system' && mq('(prefers-color-scheme: dark)'));
var motion = typeof s.reduceMotion === 'boolean' ? s.reduceMotion : mq('(prefers-reduced-motion: reduce)');
var c = document.documentElement.classList;
if (dark) { c.add('theme-dark', 'van-theme-dark'); document.querySelector('meta[name=theme-color]').setAttribute('content', '#15171A'); }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 17 KiB

+4
View File
@@ -21,6 +21,7 @@
</component>
<AppUpdateGate />
<InstallPromptModal />
<ProductMorph />
<ToastHost />
@@ -34,16 +35,19 @@ import { useRegisterSW } from 'virtual:pwa-register/vue'
import MasterLayout from './layout/MasterLayout.vue'
import FullPageLayout from './layout/FullPageLayout.vue'
import AppUpdateGate from './components/AppUpdateGate.vue'
import InstallPromptModal from './components/pwa/InstallPromptModal.vue'
import ToastHost from './components/ui/ToastHost.vue'
import ProductMorph from './components/product/ProductMorph.vue'
import ActingAsBar from './components/expert/ActingAsBar.vue'
import { useSessionStore } from './stores/session'
import { usePwaInstall } from './composables/usePwaInstall'
const router = useRouter()
const session = useSessionStore()
// Registers the service worker (offline, install). Updates are not applied
// here: AppUpdateGate shows what changed and applies them on the user's tap.
useRegisterSW()
usePwaInstall().start(router)
const getLayout = computed(() => {
if (router.currentRoute.value.meta && router.currentRoute.value.meta.layout === 'FullPage') {
+1 -4
View File
@@ -7,10 +7,7 @@ export function useCachedReads() {
const cache = useQueryCache()
return {
me: (o?: FetchOptions) => cache.fetch(KEYS.profile, api.getMe, o),
// Orders are paged (infinite scroll). The list screen calls api.getMyOrders
// directly; this helper only covers a rare full-first-page peek/invalidate.
myOrders: (o?: FetchOptions) =>
cache.fetch(KEYS.orders, () => api.getMyOrders({ page: 1 }).then((p) => p.result), o),
myOrders: (o?: FetchOptions) => cache.fetch(KEYS.orders, api.getMyOrders, o),
accountingBalance: (o?: FetchOptions) => cache.fetch(KEYS.balance, api.getAccountingBalance, o),
myLedgerEntries: (o?: FetchOptions) => cache.fetch(KEYS.ledger, api.getMyLedgerEntries, o),
goldPrice: (o?: FetchOptions) => cache.fetch(KEYS.goldPrice, () => api.getLatestGoldPrice(), o),
Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 87 KiB

@@ -1,25 +1,25 @@
<svg width="138" height="137" viewBox="0 0 138 137" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M122.973 63.8852C122.973 77.8009 118.307 90.7045 110.489 101.078C106.201 106.644 101.031 111.578 95.1037 115.373C85.5197 121.698 74.0442 125.367 61.6859 125.367C27.7636 125.367 0.272705 97.7888 0.272705 63.8852C0.272705 29.8551 27.7636 2.40332 61.5598 2.40332C73.9181 2.40332 85.3936 6.07199 94.9776 12.2708C100.905 16.066 106.075 20.9997 110.362 26.566C118.307 37.066 122.973 49.9696 122.973 63.8852Z" fill="#EAEEF9"/>
<path d="M122.973 63.8852C122.973 77.8009 118.307 90.7045 110.489 101.078C106.201 106.644 101.031 111.578 95.1037 115.373C85.5197 121.698 74.0442 125.367 61.6859 125.367C27.7636 125.367 0.272705 97.7888 0.272705 63.8852C0.272705 29.8551 27.7636 2.40332 61.5598 2.40332C73.9181 2.40332 85.3936 6.07199 94.9776 12.2708C100.905 16.066 106.075 20.9997 110.362 26.566C118.307 37.066 122.973 49.9696 122.973 63.8852Z" fill="#FBF2F4"/>
<path d="M3.29943 21.7587C4.43438 21.7587 5.31711 20.7467 5.31711 19.6081C5.31711 18.4696 4.43438 17.4575 3.17333 17.4575C2.03838 17.4575 1.02954 18.3431 1.02954 19.6081C1.15565 20.7467 2.16449 21.7587 3.29943 21.7587Z" fill="url(#paint0_linear_460_3936)"/>
<path d="M3.42553 117.777C4.56047 117.777 5.56931 116.891 5.56931 115.626C5.56931 114.488 4.68658 113.476 3.42553 113.476C2.29058 113.476 1.28174 114.361 1.28174 115.626C1.40784 116.765 2.29058 117.777 3.42553 117.777Z" fill="url(#paint1_linear_460_3936)"/>
<path d="M130.161 45.1624C132.053 43.3913 132.179 40.4817 130.413 38.5841C128.648 36.6865 125.747 36.56 123.73 38.331C121.838 40.1021 121.712 43.0118 123.477 44.9094C125.243 46.6804 128.269 46.807 130.161 45.1624Z" fill="#EAEEF9"/>
<path d="M130.161 45.1624C132.053 43.3913 132.179 40.4817 130.413 38.5841C128.648 36.6865 125.747 36.56 123.73 38.331C121.838 40.1021 121.712 43.0118 123.477 44.9094C125.243 46.6804 128.269 46.807 130.161 45.1624Z" fill="#FBF2F4"/>
<path d="M129.152 24.5418C130.287 24.5418 131.17 23.6563 131.17 22.5177C131.17 21.3792 130.287 20.4937 129.152 20.4937C128.017 20.4937 127.135 21.3792 127.135 22.5177C127.135 23.6563 128.017 24.5418 129.152 24.5418Z" fill="url(#paint2_linear_460_3936)"/>
<path d="M81.7367 116.639C81.7367 121.319 77.9536 125.241 73.1616 125.241H27.3854C22.7196 125.241 18.8103 121.446 18.8103 116.639V8.60241C18.8103 3.92169 22.5935 0 27.3854 0H73.5399C78.2058 0 82.1151 3.79518 82.1151 8.60241V116.765H81.7367V116.639Z" fill="white"/>
<path d="M73.5399 0C78.2058 0 82.1151 3.79518 82.1151 8.60241V117.018C82.1151 121.699 78.3319 125.62 73.5399 125.62H27.3854C22.7196 125.62 18.8103 121.825 18.8103 117.018V8.60241C18.8103 3.92169 22.5935 0 27.3854 0H73.5399Z" fill="#1C3754"/>
<path d="M73.5399 0C78.2058 0 82.1151 3.79518 82.1151 8.60241V117.018C82.1151 121.699 78.3319 125.62 73.5399 125.62H27.3854C22.7196 125.62 18.8103 121.825 18.8103 117.018V8.60241C18.8103 3.92169 22.5935 0 27.3854 0H73.5399Z" fill="#3A1F28"/>
<path d="M77.7014 8.223V116.639C77.7014 118.536 76.5664 120.054 74.801 120.56C74.4227 120.687 74.0443 120.687 73.5399 120.687H27.3855C27.1333 120.687 26.881 120.687 26.7549 120.687C24.7373 120.307 23.3501 118.663 23.3501 116.639V8.60251C23.3501 6.45191 24.7373 4.80733 26.7549 4.55432C27.0071 4.55432 27.1333 4.55432 27.3855 4.55432H34.1951C35.0779 4.55432 35.7084 5.18685 35.7084 6.07239C35.7084 7.84348 37.2217 9.36155 38.9871 9.36155H62.4427C64.2081 9.36155 65.7214 7.84348 65.7214 6.07239C65.5953 5.06035 66.2258 4.42782 67.1086 4.42782H73.5399V4.1748C73.9182 4.1748 74.4227 4.1748 74.801 4.30131C76.5664 4.80733 77.7014 6.32541 77.7014 8.223Z" fill="white"/>
<path d="M54.4979 5.56649H48.3188C47.6882 5.56649 47.436 4.93396 47.436 4.68095C47.436 4.04842 48.0666 3.79541 48.3188 3.79541H54.4979C55.1284 3.79541 55.3807 4.42794 55.3807 4.68095C55.3807 5.31348 55.0023 5.56649 54.4979 5.56649Z" fill="#2D5887"/>
<path d="M44.7878 5.56649H44.5356C43.905 5.56649 43.6528 4.93396 43.6528 4.68095C43.6528 4.04842 44.2834 3.79541 44.5356 3.79541H44.7878C45.4183 3.79541 45.6705 4.42794 45.6705 4.68095C45.6705 5.31348 45.4183 5.56649 44.7878 5.56649Z" fill="#2D5887"/>
<path d="M54.4979 5.56649H48.3188C47.6882 5.56649 47.436 4.93396 47.436 4.68095C47.436 4.04842 48.0666 3.79541 48.3188 3.79541H54.4979C55.1284 3.79541 55.3807 4.42794 55.3807 4.68095C55.3807 5.31348 55.0023 5.56649 54.4979 5.56649Z" fill="#7A1B33"/>
<path d="M44.7878 5.56649H44.5356C43.905 5.56649 43.6528 4.93396 43.6528 4.68095C43.6528 4.04842 44.2834 3.79541 44.5356 3.79541H44.7878C45.4183 3.79541 45.6705 4.42794 45.6705 4.68095C45.6705 5.31348 45.4183 5.56649 44.7878 5.56649Z" fill="#7A1B33"/>
<path d="M70.8914 92.2231H30.6639C28.5201 92.2231 26.7546 90.452 26.7546 88.3014V37.8255C26.7546 35.6749 28.5201 33.9038 30.6639 33.9038H70.8914C73.0352 33.9038 74.8007 35.6749 74.8007 37.8255V88.3014C74.8007 90.452 73.0352 92.2231 70.8914 92.2231Z" fill="white"/>
<path d="M70.8914 92.2231H30.6639C28.5201 92.2231 26.7546 90.452 26.7546 88.3014V37.8255C26.7546 35.6749 28.5201 33.9038 30.6639 33.9038H70.8914C73.0352 33.9038 74.8007 35.6749 74.8007 37.8255V88.3014C74.8007 90.452 73.0352 92.2231 70.8914 92.2231Z" fill="#F5F7FD" stroke="#E4E8F5" stroke-width="0.9843" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="1.98 1.98"/>
<path d="M74.8007 4.30131V24.1627C74.8007 26.3134 73.0352 28.0844 70.8914 28.0844H30.6639C28.5201 28.0844 26.7546 26.3134 26.7546 24.1627V4.42782C27.0068 4.42782 27.133 4.42782 27.3852 4.42782H34.1948C35.0776 4.42782 35.7081 5.06035 35.7081 5.94589C35.7081 7.71697 37.2214 9.23504 38.9868 9.23504H62.4424C64.2079 9.23504 65.7211 7.71697 65.7211 5.94589C65.595 5.06035 66.2255 4.42782 67.1083 4.42782H73.5396V4.1748C74.044 4.1748 74.4224 4.1748 74.8007 4.30131Z" fill="#EAEEF9"/>
<path d="M74.8007 101.711V120.687C74.4224 120.813 74.044 120.813 73.5396 120.813H27.3852C27.133 120.813 26.8807 120.813 26.7546 120.813V101.711C26.7546 99.5601 28.5201 97.7891 30.6639 97.7891H70.8914C73.0352 97.7891 74.8007 99.5601 74.8007 101.711Z" fill="#EAEEF9"/>
<path d="M70.8914 92.2231H30.6639C28.5201 92.2231 26.7546 90.452 26.7546 88.3014V37.8255C26.7546 35.6749 28.5201 33.9038 30.6639 33.9038H70.8914C73.0352 33.9038 74.8007 35.6749 74.8007 37.8255V88.3014C74.8007 90.452 73.0352 92.2231 70.8914 92.2231Z" fill="#FBF2F4" stroke="#F0E0E4" stroke-width="0.9843" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="1.98 1.98"/>
<path d="M74.8007 4.30131V24.1627C74.8007 26.3134 73.0352 28.0844 70.8914 28.0844H30.6639C28.5201 28.0844 26.7546 26.3134 26.7546 24.1627V4.42782C27.0068 4.42782 27.133 4.42782 27.3852 4.42782H34.1948C35.0776 4.42782 35.7081 5.06035 35.7081 5.94589C35.7081 7.71697 37.2214 9.23504 38.9868 9.23504H62.4424C64.2079 9.23504 65.7211 7.71697 65.7211 5.94589C65.595 5.06035 66.2255 4.42782 67.1083 4.42782H73.5396V4.1748C74.044 4.1748 74.4224 4.1748 74.8007 4.30131Z" fill="#FBF2F4"/>
<path d="M74.8007 101.711V120.687C74.4224 120.813 74.044 120.813 73.5396 120.813H27.3852C27.133 120.813 26.8807 120.813 26.7546 120.813V101.711C26.7546 99.5601 28.5201 97.7891 30.6639 97.7891H70.8914C73.0352 97.7891 74.8007 99.5601 74.8007 101.711Z" fill="#FBF2F4"/>
<g filter="url(#filter0_d_460_3936)">
<path d="M97.6227 96.3736H50.6865C46.8808 96.3736 43.8 93.283 43.8 89.4652V31.8355C43.8 28.0178 46.8808 24.9272 50.6865 24.9272H97.6227C101.428 24.9272 104.509 28.0178 104.509 31.8355V89.4652C104.509 93.283 101.428 96.3736 97.6227 96.3736Z" fill="white"/>
</g>
<path d="M89.2864 77.8301H53.0422C52.1361 77.8301 51.23 77.103 51.23 76.3758C51.23 75.4668 51.9549 74.9214 53.0422 74.9214H89.2864C90.1925 74.9214 91.0986 75.6486 91.0986 76.3758C90.9174 77.103 90.1925 77.8301 89.2864 77.8301Z" fill="url(#paint3_linear_460_3936)"/>
<path d="M67.3587 86.5567H52.861C51.9549 86.5567 51.0488 85.8295 51.0488 85.1023C51.0488 84.1933 51.7737 83.6479 52.861 83.6479H67.3587C68.2648 83.6479 69.1709 84.3751 69.1709 85.1023C68.9897 85.8295 68.2648 86.5567 67.3587 86.5567Z" fill="url(#paint4_linear_460_3936)"/>
<path d="M95.2668 66.0132H53.5859C52.4986 66.0132 51.7737 65.1042 51.7737 64.1953V35.4713C51.7737 34.3805 52.6798 33.6533 53.5859 33.6533H95.2668C96.3541 33.6533 97.079 34.5623 97.079 35.4713V64.1953C97.079 65.286 96.1729 66.0132 95.2668 66.0132Z" fill="#E6ECF5"/>
<path d="M75.1512 59.105H60.1099L67.7211 50.5605L75.1512 59.105Z" fill="#CED7E2"/>
<path d="M95.2668 66.0132H53.5859C52.4986 66.0132 51.7737 65.1042 51.7737 64.1953V35.4713C51.7737 34.3805 52.6798 33.6533 53.5859 33.6533H95.2668C96.3541 33.6533 97.079 34.5623 97.079 35.4713V64.1953C97.079 65.286 96.1729 66.0132 95.2668 66.0132Z" fill="#F3E6E9"/>
<path d="M75.1512 59.105H60.1099L67.7211 50.5605L75.1512 59.105Z" fill="#D4B8C0"/>
<path d="M87.6556 59.1049H66.0903L76.7824 46.7427L87.6556 59.1049Z" fill="white"/>
<path d="M65.9091 47.4699C67.5104 47.4699 68.8086 46.1676 68.8086 44.5611C68.8086 42.9546 67.5104 41.6523 65.9091 41.6523C64.3077 41.6523 63.0095 42.9546 63.0095 44.5611C63.0095 46.1676 64.3077 47.4699 65.9091 47.4699Z" fill="url(#paint5_linear_460_3936)"/>
<path d="M82.3671 14.674C82.7454 17.9632 79.9711 21.7584 76.0619 21.7584C72.657 21.7584 69.7566 18.5957 69.7566 15.0535V15.1801C69.6305 19.3548 66.2309 21.4173 62.7 20.5318H62.3164C59.7943 20.0258 57.1461 18.5957 57.1461 15.3066C57.1461 18.8487 54.3718 21.7584 50.8409 21.7584C47.3099 21.7584 44.5356 18.4692 44.5356 15.0535C44.5356 18.5957 41.6352 21.7584 38.2303 21.7584C34.6994 21.7584 31.9251 18.8487 31.9251 15.3066C31.9251 18.7222 29.1508 21.7584 25.6198 21.7584C22.0889 21.7584 19.0624 18.8487 19.3146 14.674C19.3146 10.8789 18.4318 12.6499 26.3765 3.5415H75.4314C82.9977 12.5234 82.3671 10.6258 82.3671 14.674Z" fill="url(#paint6_linear_460_3936)"/>
@@ -27,7 +27,7 @@
<path d="M69.7566 15.1806C69.7566 19.4818 65.7213 22.2649 62.3164 21.5059H62.1903C59.416 20.9999 57.1461 18.2167 57.1461 14.9276C57.1461 10.8794 57.5244 12.777 54.624 3.41553H64.9646L65.8474 4.68059C70.0088 12.6505 69.7566 11.0059 69.7566 15.1806Z" fill="white"/>
<path d="M134.953 97.7886L130.287 121.825C129.909 123.975 128.143 125.493 126 125.493H88.9247C86.7809 125.493 85.0154 123.975 84.6371 121.825L79.9712 97.7886" fill="white"/>
<path d="M134.953 97.7886L134.575 99.8127V100.066L130.413 121.825C130.035 123.975 128.269 125.493 126.126 125.493H88.9245C86.7807 125.493 85.0153 123.975 84.637 121.825L80.4755 100.066V99.8127L80.0972 97.7886H134.953Z" fill="url(#paint7_linear_460_3936)"/>
<path d="M134.953 97.7886L134.575 99.8127H80.4756L79.9712 97.7886H134.953Z" fill="#526DFF"/>
<path d="M134.953 97.7886L134.575 99.8127H80.4756L79.9712 97.7886H134.953Z" fill="#7A1B33"/>
<path d="M81.7367 97.7889H80.3495C78.7102 97.7889 77.323 96.5238 77.323 95.0058V92.0961C77.323 90.5781 78.7102 89.313 80.3495 89.313H134.701C136.34 89.313 137.727 90.5781 137.727 92.0961V95.0058C137.727 96.5238 136.34 97.7889 134.701 97.7889H133.314" fill="url(#paint8_linear_460_3936)"/>
<path d="M91.5726 104.114C91.4465 103.481 90.816 103.102 90.1855 103.228C89.5549 103.355 89.1766 103.987 89.3027 104.62L92.2031 119.168C92.4554 120.686 94.8513 120.307 94.473 118.662L91.5726 104.114Z" fill="url(#paint9_linear_460_3936)"/>
<path d="M99.5175 104.241C99.3914 103.609 98.887 103.102 98.1303 103.229C97.4998 103.355 96.9954 103.862 97.1215 104.621C97.4998 107.657 98.7609 116.006 99.1392 119.169C99.3914 120.687 101.661 120.434 101.409 118.789C101.409 117.398 99.7697 105.886 99.5175 104.241Z" fill="url(#paint10_linear_460_3936)"/>
@@ -36,22 +36,22 @@
<path d="M123.351 104.114C122.973 106.012 120.577 117.904 120.451 118.663C120.199 120.181 122.469 120.687 122.721 119.169L125.621 104.62C126 103.102 123.73 102.596 123.351 104.114Z" fill="url(#paint13_linear_460_3936)"/>
<g filter="url(#filter1_d_460_3936)">
<path d="M89.6123 64.7738C90.6894 64.7738 91.551 65.6354 91.551 66.7125V79.2068C91.551 82.0072 89.3968 84.1614 86.5964 84.1614H78.4105C77.5488 84.1614 76.6872 83.946 76.2563 83.2997C74.3176 81.5764 70.2246 78.1297 69.363 76.8372C69.1475 76.4063 68.9321 76.1909 68.9321 75.7601V72.9596C68.9321 71.2363 70.2246 69.9438 71.948 69.9438H73.0251V75.3292H73.6713V64.1275C73.6713 63.0504 74.533 62.1887 75.6101 62.1887C76.6872 62.1887 77.5489 63.0504 77.5489 64.1275V66.2817H78.1951V63.4812C78.1951 62.4041 79.0568 61.5425 80.1339 61.5425C81.211 61.5425 82.0726 62.4041 82.0726 63.4812V66.2817H82.9343V64.7738C82.9343 63.6967 83.796 62.835 84.8731 62.835C85.9501 62.835 86.8118 63.6967 86.8118 64.7738V66.2817H87.6735C87.8889 65.42 88.5352 64.7738 89.6123 64.7738Z" fill="white"/>
<path d="M89.6123 64.7738C90.6894 64.7738 91.551 65.6354 91.551 66.7125V79.2068C91.551 82.0072 89.3968 84.1614 86.5964 84.1614H78.4105C77.5488 84.1614 76.6872 83.946 76.2563 83.2997C74.3176 81.5764 70.2246 78.1297 69.363 76.8372C69.1475 76.4063 68.9321 76.1909 68.9321 75.7601V72.9596C68.9321 71.2363 70.2246 69.9438 71.948 69.9438H73.0251V75.3292H73.6713V64.1275C73.6713 63.0504 74.533 62.1887 75.6101 62.1887C76.6872 62.1887 77.5489 63.0504 77.5489 64.1275V66.2817H78.1951V63.4812C78.1951 62.4041 79.0568 61.5425 80.1339 61.5425C81.211 61.5425 82.0726 62.4041 82.0726 63.4812V66.2817H82.9343V64.7738C82.9343 63.6967 83.796 62.835 84.8731 62.835C85.9501 62.835 86.8118 63.6967 86.8118 64.7738V66.2817H87.6735C87.8889 65.42 88.5352 64.7738 89.6123 64.7738Z" stroke="#1C3754" stroke-width="1.408" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M89.6123 64.7738C90.6894 64.7738 91.551 65.6354 91.551 66.7125V79.2068C91.551 82.0072 89.3968 84.1614 86.5964 84.1614H78.4105C77.5488 84.1614 76.6872 83.946 76.2563 83.2997C74.3176 81.5764 70.2246 78.1297 69.363 76.8372C69.1475 76.4063 68.9321 76.1909 68.9321 75.7601V72.9596C68.9321 71.2363 70.2246 69.9438 71.948 69.9438H73.0251V75.3292H73.6713V64.1275C73.6713 63.0504 74.533 62.1887 75.6101 62.1887C76.6872 62.1887 77.5489 63.0504 77.5489 64.1275V66.2817H78.1951V63.4812C78.1951 62.4041 79.0568 61.5425 80.1339 61.5425C81.211 61.5425 82.0726 62.4041 82.0726 63.4812V66.2817H82.9343V64.7738C82.9343 63.6967 83.796 62.835 84.8731 62.835C85.9501 62.835 86.8118 63.6967 86.8118 64.7738V66.2817H87.6735C87.8889 65.42 88.5352 64.7738 89.6123 64.7738Z" stroke="#3A1F28" stroke-width="1.408" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<path d="M69.1261 105.885V120.813H32.5557V105.885C32.5557 105 33.1862 104.367 34.0689 104.367H67.739C68.3695 104.367 69.1261 105 69.1261 105.885Z" fill="white"/>
<path d="M47.8146 120.813H42.8965L45.4186 118.03L47.6885 120.686L47.8146 120.813Z" fill="#CED7E2"/>
<path d="M57.903 120.813H47.6885V120.687L52.7327 114.994L57.903 120.813Z" fill="#EAEEF9"/>
<path d="M43.9054 115.5C45.159 115.5 46.1753 114.481 46.1753 113.223C46.1753 111.965 45.159 110.946 43.9054 110.946C42.6518 110.946 41.6355 111.965 41.6355 113.223C41.6355 114.481 42.6518 115.5 43.9054 115.5Z" fill="#CED7E2"/>
<path d="M47.8146 120.813H42.8965L45.4186 118.03L47.6885 120.686L47.8146 120.813Z" fill="#D4B8C0"/>
<path d="M57.903 120.813H47.6885V120.687L52.7327 114.994L57.903 120.813Z" fill="#FBF2F4"/>
<path d="M43.9054 115.5C45.159 115.5 46.1753 114.481 46.1753 113.223C46.1753 111.965 45.159 110.946 43.9054 110.946C42.6518 110.946 41.6355 111.965 41.6355 113.223C41.6355 114.481 42.6518 115.5 43.9054 115.5Z" fill="#D4B8C0"/>
<path opacity="0.2" d="M82.2409 12.1444H19.3145C19.4406 11.6384 19.5667 11.2589 19.945 10.8794L26.2502 3.41553H75.3051L81.6104 10.8794C81.9887 11.2589 82.1148 11.6384 82.2409 12.1444Z" fill="url(#paint14_linear_460_3936)"/>
<path d="M10.3611 126H136.214" stroke="#B1B9CC" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M1.15527 126H5.94727" stroke="#B1B9CC" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.3611 126H136.214" stroke="#E5D4D8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M1.15527 126H5.94727" stroke="#E5D4D8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<defs>
<filter id="filter0_d_460_3936" x="21.8" y="20.9272" width="104.709" height="115.446" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="18"/>
<feGaussianBlur stdDeviation="11"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.397708 0 0 0 0 0.47749 0 0 0 0 0.575 0 0 0 0.35 0"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.478 0 0 0 0 0.106 0 0 0 0 0.2 0 0 0 0.35 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_460_3936"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_460_3936" result="shape"/>
</filter>
@@ -60,69 +60,69 @@
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="5"/>
<feGaussianBlur stdDeviation="3"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.397708 0 0 0 0 0.47749 0 0 0 0 0.575 0 0 0 0.28 0"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.478 0 0 0 0 0.106 0 0 0 0 0.2 0 0 0 0.28 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_460_3936"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_460_3936" result="shape"/>
</filter>
<linearGradient id="paint0_linear_460_3936" x1="1.16737" y1="19.6458" x2="5.24385" y2="19.6458" gradientUnits="userSpaceOnUse">
<stop stop-color="#83A6FF"/>
<stop offset="1" stop-color="#5A78FF"/>
<stop stop-color="#C45A72"/>
<stop offset="1" stop-color="#7A1B33"/>
</linearGradient>
<linearGradient id="paint1_linear_460_3936" x1="1.38111" y1="115.639" x2="5.45746" y2="115.639" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF66A9"/>
<stop offset="1" stop-color="#F53689"/>
<stop stop-color="#A33C55"/>
<stop offset="1" stop-color="#7A1B33"/>
</linearGradient>
<linearGradient id="paint2_linear_460_3936" x1="127.196" y1="22.6043" x2="131.024" y2="22.6043" gradientUnits="userSpaceOnUse">
<stop stop-color="#83A6FF"/>
<stop offset="1" stop-color="#5A78FF"/>
<stop stop-color="#C45A72"/>
<stop offset="1" stop-color="#7A1B33"/>
</linearGradient>
<linearGradient id="paint3_linear_460_3936" x1="51.1903" y1="76.3398" x2="90.9981" y2="76.3398" gradientUnits="userSpaceOnUse">
<stop stop-color="#CED7E2"/>
<stop offset="1" stop-color="#E6ECF5"/>
<stop stop-color="#D4B8C0"/>
<stop offset="1" stop-color="#F3E6E9"/>
</linearGradient>
<linearGradient id="paint4_linear_460_3936" x1="51.1904" y1="84.946" x2="69.1235" y2="84.946" gradientUnits="userSpaceOnUse">
<stop stop-color="#CED7E2"/>
<stop offset="1" stop-color="#E6ECF5"/>
<stop stop-color="#D4B8C0"/>
<stop offset="1" stop-color="#F3E6E9"/>
</linearGradient>
<linearGradient id="paint5_linear_460_3936" x1="63.0354" y1="44.5649" x2="68.7136" y2="44.5649" gradientUnits="userSpaceOnUse">
<stop stop-color="#83A6FF"/>
<stop offset="1" stop-color="#5A78FF"/>
<stop stop-color="#C45A72"/>
<stop offset="1" stop-color="#7A1B33"/>
</linearGradient>
<linearGradient id="paint6_linear_460_3936" x1="19.2079" y1="12.5628" x2="82.3288" y2="12.5628" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF66A9"/>
<stop offset="1" stop-color="#F53689"/>
<stop stop-color="#A33C55"/>
<stop offset="1" stop-color="#7A1B33"/>
</linearGradient>
<linearGradient id="paint7_linear_460_3936" x1="80.0291" y1="111.653" x2="134.998" y2="111.653" gradientUnits="userSpaceOnUse">
<stop stop-color="#83A6FF"/>
<stop offset="1" stop-color="#5A78FF"/>
<stop stop-color="#C45A72"/>
<stop offset="1" stop-color="#7A1B33"/>
</linearGradient>
<linearGradient id="paint8_linear_460_3936" x1="77.3017" y1="93.59" x2="137.764" y2="93.59" gradientUnits="userSpaceOnUse">
<stop stop-color="#83A6FF"/>
<stop offset="1" stop-color="#5A78FF"/>
<stop stop-color="#C45A72"/>
<stop offset="1" stop-color="#7A1B33"/>
</linearGradient>
<linearGradient id="paint9_linear_460_3936" x1="91.9007" y1="118.239" x2="91.9007" y2="104.032" gradientUnits="userSpaceOnUse">
<stop stop-color="#BDCFFF"/>
<stop offset="1" stop-color="#91A5FF" stop-opacity="0.5"/>
<stop stop-color="#E8B8C4"/>
<stop offset="1" stop-color="#C45A72" stop-opacity="0.5"/>
</linearGradient>
<linearGradient id="paint10_linear_460_3936" x1="99.4024" y1="118.24" x2="99.4024" y2="104.033" gradientUnits="userSpaceOnUse">
<stop stop-color="#BDCFFF"/>
<stop offset="1" stop-color="#91A5FF" stop-opacity="0.5"/>
<stop stop-color="#E8B8C4"/>
<stop offset="1" stop-color="#C45A72" stop-opacity="0.5"/>
</linearGradient>
<linearGradient id="paint11_linear_460_3936" x1="107.515" y1="118.24" x2="107.515" y2="104.033" gradientUnits="userSpaceOnUse">
<stop stop-color="#BDCFFF"/>
<stop offset="1" stop-color="#91A5FF" stop-opacity="0.5"/>
<stop stop-color="#E8B8C4"/>
<stop offset="1" stop-color="#C45A72" stop-opacity="0.5"/>
</linearGradient>
<linearGradient id="paint12_linear_460_3936" x1="115.63" y1="118.239" x2="115.63" y2="104.032" gradientUnits="userSpaceOnUse">
<stop stop-color="#BDCFFF"/>
<stop offset="1" stop-color="#91A5FF" stop-opacity="0.5"/>
<stop stop-color="#E8B8C4"/>
<stop offset="1" stop-color="#C45A72" stop-opacity="0.5"/>
</linearGradient>
<linearGradient id="paint13_linear_460_3936" x1="123.132" y1="118.24" x2="123.132" y2="104.033" gradientUnits="userSpaceOnUse">
<stop stop-color="#BDCFFF"/>
<stop offset="1" stop-color="#91A5FF" stop-opacity="0.5"/>
<stop stop-color="#E8B8C4"/>
<stop offset="1" stop-color="#C45A72" stop-opacity="0.5"/>
</linearGradient>
<linearGradient id="paint14_linear_460_3936" x1="50.7725" y1="11.0545" x2="50.7725" y2="2.70355" gradientUnits="userSpaceOnUse">
<stop offset="0.00289017" stop-color="#3F5073" stop-opacity="0"/>
<stop offset="1" stop-color="#283247"/>
<stop offset="0.00289017" stop-color="#5C2A38" stop-opacity="0"/>
<stop offset="1" stop-color="#3A1F28"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 87 KiB

+71 -24
View File
@@ -1,22 +1,71 @@
/* IRANSans FaNum — primary typeface for the customer app (same files as admin-panel). */
@font-face {
font-family: iransans;
font-style: normal;
font-weight: 200;
src: url('./fonts/iransans/IRANSansWeb_FaNum_UltraLight.woff2') format('woff2'),
url('./fonts/iransans/IRANSansWeb_FaNum_UltraLight.woff') format('woff');
}
@font-face {
font-family: iransans;
font-style: normal;
font-weight: 300;
src: url('./fonts/iransans/IRANSansWeb_FaNum_Light.woff2') format('woff2'),
url('./fonts/iransans/IRANSansWeb_FaNum_Light.woff') format('woff');
}
@font-face {
font-family: iransans;
font-style: normal;
font-weight: normal;
src: url('./fonts/iransans/IRANSansWeb_FaNum.woff2') format('woff2'),
url('./fonts/iransans/IRANSansWeb_FaNum.woff') format('woff');
}
@font-face {
font-family: iransans;
font-style: normal;
font-weight: 500;
src: url('./fonts/iransans/IRANSansWeb_FaNum_Medium.woff2') format('woff2'),
url('./fonts/iransans/IRANSansWeb_FaNum_Medium.woff') format('woff');
}
@font-face {
font-family: iransans;
font-style: normal;
font-weight: bold;
src: url('./fonts/iransans/IRANSansWeb_FaNum_Bold.woff2') format('woff2'),
url('./fonts/iransans/IRANSansWeb_FaNum_Bold.woff') format('woff');
}
@font-face {
font-family: iransans;
font-style: normal;
font-weight: 900;
src: url('./fonts/iransans/IRANSansWeb_FaNum_Black.woff2') format('woff2'),
url('./fonts/iransans/IRANSansWeb_FaNum_Black.woff') format('woff');
}
/* Yekan Bakh kept as a fallback only (legacy files still on disk). */
@font-face {
font-family: yekan_bakh;
font-style: normal;
font-weight: bold;
src: url('./fonts/yekan_bakh_fanum_bold.eot');
src: url('./fonts/yekan_bakh_fanum_bold.eot?#iefix') format('embedded-opentype'), /* IE6-8 */
url('./fonts/yekan_bakh_fanum_bold.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/
url('./fonts/yekan_bakh_fanum_bold.ttf') format('truetype');
src: url('./fonts/yekan_bakh_fanum_bold.eot?#iefix') format('embedded-opentype'),
url('./fonts/yekan_bakh_fanum_bold.woff') format('woff'),
url('./fonts/yekan_bakh_fanum_bold.ttf') format('truetype');
}
@font-face {
font-family: yekan_bakh;
font-style: normal;
font-weight: 100;
src: url('./fonts/yekan_bakh_fanum_thin.eot');
src: url('./fonts/yekan_bakh_fanum_thin.eot?#iefix') format('embedded-opentype'), /* IE6-8 */
url('./fonts/yekan_bakh_fanum_thin.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/
url('./fonts/yekan_bakh_fanum_thin.ttf') format('truetype');
src: url('./fonts/yekan_bakh_fanum_thin.eot?#iefix') format('embedded-opentype'),
url('./fonts/yekan_bakh_fanum_thin.woff') format('woff'),
url('./fonts/yekan_bakh_fanum_thin.ttf') format('truetype');
}
@font-face {
@@ -24,9 +73,9 @@
font-style: normal;
font-weight: 300;
src: url('./fonts/yekan_bakh_fanum_light.eot');
src: url('./fonts/yekan_bakh_fanum_light.eot?#iefix') format('embedded-opentype'), /* IE6-8 */
url('./fonts/yekan_bakh_fanum_light.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/
url('./fonts/yekan_bakh_fanum_light.ttf') format('truetype');
src: url('./fonts/yekan_bakh_fanum_light.eot?#iefix') format('embedded-opentype'),
url('./fonts/yekan_bakh_fanum_light.woff') format('woff'),
url('./fonts/yekan_bakh_fanum_light.ttf') format('truetype');
}
@font-face {
@@ -34,9 +83,9 @@
font-style: normal;
font-weight: normal;
src: url('./fonts/yekan_bakh_fanum_regular.eot');
src: url('./fonts/yekan_bakh_fanum_regular.eot?#iefix') format('embedded-opentype'), /* IE6-8 */
url('./fonts/yekan_bakh_fanum_regular.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/
url('./fonts/yekan_bakh_fanum_regular.ttf') format('truetype');
src: url('./fonts/yekan_bakh_fanum_regular.eot?#iefix') format('embedded-opentype'),
url('./fonts/yekan_bakh_fanum_regular.woff') format('woff'),
url('./fonts/yekan_bakh_fanum_regular.ttf') format('truetype');
}
@font-face {
@@ -44,9 +93,9 @@
font-style: normal;
font-weight: 500;
src: url('./fonts/yekan_bakh_fanum_medium.eot');
src: url('./fonts/yekan_bakh_fanum_medium.eot?#iefix') format('embedded-opentype'), /* IE6-8 */
url('./fonts/yekan_bakh_fanum_medium.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/
url('./fonts/yekan_bakh_fanum_medium.ttf') format('truetype');
src: url('./fonts/yekan_bakh_fanum_medium.eot?#iefix') format('embedded-opentype'),
url('./fonts/yekan_bakh_fanum_medium.woff') format('woff'),
url('./fonts/yekan_bakh_fanum_medium.ttf') format('truetype');
}
@font-face {
@@ -54,19 +103,17 @@
font-style: normal;
font-weight: 800;
src: url('./fonts/yekan_bakh_fanum_fat.eot');
src: url('./fonts/yekan_bakh_fanum_fat.eot?#iefix') format('embedded-opentype'), /* IE6-8 */
url('./fonts/yekan_bakh_fanum_heavy.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/
url('./fonts/yekan_bakh_fanum_fat.ttf') format('truetype');
src: url('./fonts/yekan_bakh_fanum_fat.eot?#iefix') format('embedded-opentype'),
url('./fonts/yekan_bakh_fanum_heavy.woff') format('woff'),
url('./fonts/yekan_bakh_fanum_fat.ttf') format('truetype');
}
@font-face {
font-family: yekan_bakh;
font-style: normal;
font-weight: 900;
src: url('./fonts/yekan_bakh_fanum_heavy.eot');
src: url('./fonts/yekan_bakh_fanum_heavy.eot?#iefix') format('embedded-opentype'), /* IE6-8 */
url('./fonts/yekan_bakh_fanum_heavy.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/
url('./fonts/yekan_bakh_fanum_heavy.ttf') format('truetype');
src: url('./fonts/yekan_bakh_fanum_heavy.eot?#iefix') format('embedded-opentype'),
url('./fonts/yekan_bakh_fanum_heavy.woff') format('woff'),
url('./fonts/yekan_bakh_fanum_heavy.ttf') format('truetype');
}
+1 -1
View File
@@ -284,7 +284,7 @@
body, * {
font-family: yekan_bakh , sans-serif, tahoma;
font-family: iransans, yekan_bakh, sans-serif, tahoma;
//letter-spacing: 0px !important;
font-size: @font-size;
line-height: inherit;
+106 -21
View File
@@ -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>
@@ -0,0 +1,507 @@
<!--
Spring-physics card carousel for the home screen (see
docs/plans/2026-10-16-home-carousel-design.md).
Center card large, side cards scaled down; drag / flick settle with a
damped spring. Internal layout is an isolated `direction: ltr` subtree
(same rationale as GallerySlider) so translate/scale maths stay coherent
under the app's RTL chrome. Gestures are still screen-space.
-->
<template>
<section class="carousel" v-if="slides.length">
<div
ref="viewport"
class="carousel__viewport"
:class="{ 'is-dragging': dragging }"
:style="{ '--carousel-radius': `${borderRadius}px` }"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
>
<div ref="stage" class="carousel__stage">
<article
v-for="(card, i) in cards"
:key="card.key"
class="carousel__card"
:data-index="i"
:style="cardStyles[i]"
>
<img
v-if="card.image"
:src="card.image"
alt=""
class="carousel__art"
draggable="false"
/>
</article>
</div>
</div>
<div class="carousel__dots" v-if="slides.length > 1" dir="ltr">
<button
v-for="(slide, i) in slides"
:key="slide.key"
type="button"
class="carousel__dot"
:class="{ 'is-active': i === activeIndex }"
:aria-label="`اسلاید ${i + 1}`"
@click="goTo(i)"
/>
</div>
</section>
</template>
<script setup lang="ts">
import {
computed,
onActivated,
onBeforeUnmount,
onDeactivated,
onMounted,
ref,
shallowRef,
watch,
} from 'vue'
import type { ShowcaseSlide } from './ShowcaseSlider.vue'
import { useAppearanceStore } from '../../stores/appearanceStore'
const props = withDefaults(
defineProps<{
slides: ShowcaseSlide[]
sideScale?: number
gap?: number
borderRadius?: number
stiffness?: number
damping?: number
autoplay?: boolean
autoplayInterval?: number
}>(),
{
// Slightly larger neighbours so peeks read as cards, not hairlines.
sideScale: 0.72,
gap: 3,
// Match ShowcaseSlider frame radius so the active card reads the same.
borderRadius: 22,
// Premium settle: low stiffness + higher damping = slow, heavy, almost no bounce.
stiffness: 70,
damping: 28,
autoplay: true,
autoplayInterval: 7500,
},
)
/** Fraction of stage width for each card — wider center, tighter side peeks. */
const CARD_WIDTH_RATIO = 0.92
const appearance = useAppearanceStore()
/** Pad copies so wrap never shows empty slots on either side. */
const cards = computed(() => {
const base = props.slides.filter((s) => s.image)
if (!base.length) return [] as { key: string; image: string }[]
const out: { key: string; image: string }[] = []
let n = 0
while (out.length < Math.max(6, base.length * 2)) {
for (const s of base) {
out.push({ key: `${s.key}#${n++}`, image: s.image as string })
}
}
return out
})
const viewport = ref<HTMLElement | null>(null)
const stage = ref<HTMLElement | null>(null)
const dragging = ref(false)
const activeIndex = ref(0)
type CardStyle = {
opacity: string
pointerEvents: 'auto' | 'none'
zIndex: string
transform: string
}
const cardStyles = shallowRef<CardStyle[]>([])
let position = 0
let target = 0
let velocity = 0
let isDragging = false
let startX = 0
let startPosition = 0
let lastX = 0
let lastTime = 0
let dragVelocity = 0
let totalDragDistance = 0
let pointerId: number | null = null
let rafId: number | null = null
let lastFrameTime = 0
let autoplayTimer: ReturnType<typeof setInterval> | undefined
let resizeObserver: ResizeObserver | null = null
function getWrappedOffset(index: number, pos: number, length: number) {
let diff = (index - pos) % length
if (diff < -length / 2) diff += length
if (diff >= length / 2) diff -= length
return diff
}
function getActiveIndex() {
const len = props.slides.filter((s) => s.image).length || 1
return ((Math.round(target) % len) + len) % len
}
function notifyChange() {
activeIndex.value = getActiveIndex()
}
function render() {
const list = cards.value
const total = list.length
if (!total) {
cardStyles.value = []
return
}
// Center card is CARD_WIDTH_RATIO of stage; scaled neighbours peek beside it.
const stageWidth = stage.value?.clientWidth || 380
const baseCardWidth = stageWidth * CARD_WIDTH_RATIO
const sideScale = props.sideScale
const gap = props.gap
const stepDistance = baseCardWidth * 0.5 + baseCardWidth * sideScale * 0.5 + gap
const next: CardStyle[] = new Array(total)
for (let i = 0; i < total; i++) {
const offset = getWrappedOffset(i, position, total)
const absOffset = Math.abs(offset)
const clamped = Math.min(absOffset, 1)
const scale = 1 - clamped * (1 - sideScale)
const translateX = offset * stepDistance
const isVisible = absOffset < 2.1
next[i] = {
opacity: isVisible ? '1' : '0',
pointerEvents: isVisible ? 'auto' : 'none',
zIndex: String(100 - Math.round(absOffset * 20)),
transform: `translate3d(calc(-50% + ${translateX.toFixed(2)}px), 0, 0) scale(${scale.toFixed(4)})`,
}
}
cardStyles.value = next
}
function startSpringLoop() {
if (appearance.isReducedMotion) {
position = target
velocity = 0
render()
rafId = null
return
}
if (rafId != null) cancelAnimationFrame(rafId)
lastFrameTime = performance.now()
const tick = (now: number) => {
const dt = Math.min((now - lastFrameTime) / 1000, 0.064)
lastFrameTime = now
const displacement = position - target
const springForce = -props.stiffness * displacement
const dampingForce = -props.damping * velocity
const acceleration = springForce + dampingForce
velocity += acceleration * dt
position += velocity * dt
render()
if (Math.abs(velocity) < 0.002 && Math.abs(target - position) < 0.001) {
position = target
velocity = 0
render()
rafId = null
return
}
rafId = requestAnimationFrame(tick)
}
rafId = requestAnimationFrame(tick)
}
function onPointerDown(e: PointerEvent) {
if (cards.value.length < 2 || pointerId !== null) return
if (e.button !== undefined && e.button !== 0) return
const el = viewport.value
if (!el) return
pointerId = e.pointerId
isDragging = true
dragging.value = true
stopAutoplay()
if (rafId != null) {
cancelAnimationFrame(rafId)
rafId = null
}
startX = e.clientX
lastX = e.clientX
startPosition = position
velocity = 0
dragVelocity = 0
totalDragDistance = 0
lastTime = performance.now()
el.setPointerCapture?.(e.pointerId)
}
function onPointerMove(e: PointerEvent) {
if (!isDragging || e.pointerId !== pointerId) return
const now = performance.now()
const dt = Math.max(1, now - lastTime)
const dx = e.clientX - startX
const stepDx = e.clientX - lastX
totalDragDistance = Math.abs(dx)
const cardWidth = (stage.value?.clientWidth ?? 0) * CARD_WIDTH_RATIO || 320
position = startPosition - dx / cardWidth
dragVelocity = -(stepDx / cardWidth) / (dt / 1000)
lastX = e.clientX
lastTime = now
render()
}
function onPointerUp(e: PointerEvent) {
if (!isDragging || e.pointerId !== pointerId) return
isDragging = false
dragging.value = false
pointerId = null
const total = cards.value.length
// Tap a side card → spring to it
if (totalDragDistance < 6 && e.target instanceof Element) {
const clicked = e.target.closest('.carousel__card') as HTMLElement | null
if (clicked) {
const cardIdx = Number(clicked.dataset.index)
if (!Number.isNaN(cardIdx)) {
const offset = getWrappedOffset(cardIdx, position, total)
if (Math.abs(offset) > 0.25) {
target = Math.round(position + offset)
startSpringLoop()
notifyChange()
if (props.autoplay) startAutoplay()
return
}
}
}
}
velocity = dragVelocity
const projected = position + velocity * 0.16
target = Math.round(projected)
startSpringLoop()
notifyChange()
if (props.autoplay) startAutoplay()
}
function goTo(baseIndex: number) {
const len = props.slides.filter((s) => s.image).length
if (len < 2) return
const currentBase = getActiveIndex()
let diff = baseIndex - currentBase
if (diff > len / 2) diff -= len
if (diff < -len / 2) diff += len
target = Math.round(target) + diff
startSpringLoop()
notifyChange()
}
/** False while the home tab is keep-alive-cached off-screen. */
let pageActive = true
function startAutoplay() {
stopAutoplay()
if (!pageActive) return
if (!props.autoplay || appearance.isReducedMotion || props.slides.length < 2) return
autoplayTimer = setInterval(() => {
if (!isDragging && pageActive) {
target = Math.round(target) + 1
startSpringLoop()
notifyChange()
}
}, props.autoplayInterval)
}
function stopAutoplay() {
if (autoplayTimer) {
clearInterval(autoplayTimer)
autoplayTimer = undefined
}
}
/** Pause motion while cached off-screen so return doesn't catch up many slides. */
function pauseForLeave() {
pageActive = false
stopAutoplay()
if (rafId != null) {
cancelAnimationFrame(rafId)
rafId = null
}
position = target
velocity = 0
isDragging = false
dragging.value = false
render()
notifyChange()
}
function resumeOnEnter() {
pageActive = true
render()
if (props.autoplay) startAutoplay()
}
function resetToStart() {
position = 0
target = 0
velocity = 0
notifyChange()
render()
}
onMounted(() => {
pageActive = true
resetToStart()
if (stage.value && typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => render())
resizeObserver.observe(stage.value)
}
if (props.autoplay) startAutoplay()
})
// HomeView is keep-alive'd — without these, setInterval keeps advancing
// target while the user is on another tab, then the spring "catches up"
// through several slides the moment they return.
onDeactivated(pauseForLeave)
onActivated(resumeOnEnter)
watch(
() => props.slides,
() => {
resetToStart()
},
)
watch(
() => [props.sideScale, props.gap, props.stiffness, props.damping] as const,
() => render(),
)
watch(
() => props.autoplay,
(on) => {
if (on) startAutoplay()
else stopAutoplay()
},
)
onBeforeUnmount(() => {
pauseForLeave()
resizeObserver?.disconnect()
if (pointerId !== null && viewport.value) {
try {
viewport.value.releasePointerCapture(pointerId)
} catch {
/* detached */
}
}
})
</script>
<style scoped lang="less">
// Break out of Container `px-[16px]` so side peeks sit at the screen edge
// instead of leaving empty gutters inside the padded column.
.carousel {
width: calc(100% + 32px);
margin-inline: -16px;
}
.carousel__viewport {
position: relative;
width: 100%;
overflow: hidden;
touch-action: pan-y;
user-select: none;
-webkit-user-select: none;
cursor: grab;
// Top: room for bottom-anchored scale. Bottom: room for card drop-shadow
// so overflow:hidden does not shear the soft edge.
padding: 10px 0 28px;
direction: ltr;
}
.carousel__viewport.is-dragging {
cursor: grabbing;
}
// Same aspect as ShowcaseSlider; cards ~92% wide so neighbours hug the center.
.carousel__stage {
position: relative;
width: 100%;
aspect-ratio: 320 / 208;
}
.carousel__card {
position: absolute;
bottom: 0;
left: 50%;
width: 92%;
height: 100%;
transform-origin: center bottom;
will-change: transform;
border-radius: var(--carousel-radius, 22px);
overflow: hidden;
background: @hairlineSoft;
box-shadow: 0 14px 28px -12px rgba(20, 22, 26, 0.28);
}
.carousel__art {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
-webkit-user-drag: none;
user-select: none;
}
.carousel__dots {
display: flex;
justify-content: center;
gap: 6px;
// Re-align with the padded column after the bleed; extra air under the stage.
padding: 4px 16px 8px;
}
.carousel__dot {
width: 6px;
height: 6px;
padding: 0;
border: 0;
cursor: pointer;
border-radius: 999px;
background: @hairline;
transition:
width 0.42s cubic-bezier(0.16, 0.86, 0.3, 1),
background 0.3s ease;
}
.carousel__dot.is-active {
width: 20px;
background: @wine;
}
</style>
@@ -49,7 +49,7 @@ const perGram = computed(() => (props.quote ? props.quote.pricePerGramRial.split
align-items: center;
gap: 7px;
font-size: 12px;
color: #fff;
color: rgba(255, 255, 255, 0.68);
white-space: nowrap;
}
@@ -82,13 +82,12 @@ const perGram = computed(() => (props.quote ? props.quote.pricePerGramRial.split
.rate-strip__unit {
font-size: 10.5px;
/* Tailwind gray-100 */
color: #f3f4f6;
color: rgba(255, 255, 255, 0.55);
}
.rate-strip__time {
font-size: 10.5px;
color: #fff;
color: rgba(255, 255, 255, 0.42);
padding-inline-start: 10px;
border-inline-start: 1px solid rgba(255, 255, 255, 0.14);
white-space: nowrap;
@@ -7,7 +7,7 @@
</div>
<button v-if="actionLabel" type="button" class="sec__action" @click="emit('action')">
{{ actionLabel }}
<Icon icon="vuesax-linear:arrow-left-1" size="15" />
<Icon icon="vuesax-linear:arrow-left-2" size="15" />
</button>
</header>
</template>
@@ -30,8 +30,8 @@ const emit = defineEmits<{ (e: 'action'): void }>()
.sec__title {
margin: 0;
font-size: 13.5px;
font-weight: 600;
font-size: 15px;
font-weight: 800;
color: @ink;
letter-spacing: -0.1px;
}
@@ -1,164 +0,0 @@
<!--
Product card for the redesigned home screen.
Shows only fields that genuinely exist on ProductSummary today: title, the
cheapest variant's rash weight, its making-charge (اجرت) percent, and the
catalogue image. No price is printed on the card, because a finished piece's
rial price is a function of the live gold rate plus wage and is computed at
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)">
<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)">
<Icon :icon="favorites.isFavorite(id) ? 'vuesax-linear:heart-fill' : 'vuesax-linear:heart'" size="17" />
</button>
<span v-if="badge" class="card__badge">{{ badge }}</span>
</div>
<div class="card__body">
<h3 class="card__title">{{ title }}</h3>
<div class="card__meta">
<span class="card__weight">{{ min_gram }} گرم</span>
<span v-if="wage != null" class="card__wage">اجرت {{ wageLabel }}٪</span>
</div>
</div>
</article>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Icon from '../icon/Icon.vue'
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 emit = defineEmits<{ (e: 'open', value: ProductSummary): void }>()
const favorites = useFavoritesStore()
const { image, id, title, min_gram, wage } = useProduct(props.value)
const wageLabel = computed(() => {
const w = wage.value
if (w == null) return ''
return w % 1 === 0 ? String(w) : w.toFixed(2)
})
</script>
<style scoped lang="less">
.card {
cursor: pointer;
background: @canvas;
border: 1px solid @hairline;
border-radius: 18px;
overflow: hidden;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.card:active {
transform: scale(0.985);
}
.card__art {
position: relative;
aspect-ratio: 1 / 1;
display: flex;
align-items: center;
justify-content: center;
background: @hairlineSoft;
}
.card--wide .card__art {
aspect-ratio: 4 / 3;
}
.card__art img {
width: 100%;
max-width: 200px;
height: 100%;
max-height: 200px;
object-fit: contain;
object-position: center;
display: block;
}
// Dark mode: product photos are shot on white, so they sit on a light mat
// instead of showing as white boxes on a dark card (@photoMat — transparent,
// i.e. no change, in light mode).
html.theme-dark .card__art img {
background: @photoMat;
border-radius: 12px;
}
.card__fav {
position: absolute;
top: 9px;
left: 9px;
width: 30px;
height: 30px;
display: grid;
place-items: center;
border: 0;
cursor: pointer;
border-radius: 50%;
color: @inkMuted;
background: alpha(@canvas, 90%);
backdrop-filter: blur(6px);
transition: color 0.18s ease, transform 0.18s ease;
}
.card__fav.is-on {
color: @wine;
}
.card__fav:active {
transform: scale(0.9);
}
.card__badge {
position: absolute;
top: 9px;
right: 9px;
padding: 4px 9px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
color: #fff;
background: @wine;
}
.card__body {
padding: 11px 12px 13px;
}
.card__title {
margin: 0;
font-size: 13px;
font-weight: 700;
color: @ink;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card__meta {
display: flex;
align-items: center;
gap: 7px;
padding-top: 7px;
}
.card__weight {
font-size: 11.5px;
font-weight: 700;
color: @inkSoft;
}
.card__wage {
font-size: 10.5px;
color: @inkFaint;
padding-inline-start: 7px;
border-inline-start: 1px solid @hairline;
}
</style>
@@ -1,33 +1,29 @@
<!--
Editorial hero for the home screen. Self-contained, dependency-free slider:
no swiper CSS to fight, no autoplay jitter, and it keeps working when a
slide has no artwork.
Editorial hero for the home screen — luxury soft crossfade between stacked
slides (see docs/plans/2026-10-15-showcase-crossfade-design.md).
Swipe support: the app sets `direction: rtl !important` on #app (see
src/App.vue), so — same fix as GallerySlider.vue — the track is an isolated
`direction: ltr` subtree (.showcase__track). Inside it, index 0 is first and
a positive translateX moves right, plain LTR arithmetic, regardless of the
page's own direction. The drag gesture itself is still read in screen space
(dragging left = next slide) so it feels natural to an RTL user; only the
track's internal layout/maths is pinned to LTR.
Self-contained, no Swiper. Swipe is still read in screen space (drag left =
next) so it feels natural under the app's RTL chrome; the track itself is no
longer a translated strip — slides sit on top of each other and fade.
-->
<template>
<section class="showcase" v-if="slides.length">
<div
ref="root"
class="showcase__frame"
:class="{ 'is-dragging': dragging }"
:class="{ 'is-dragging': dragging, 'is-reduced': appearance.isReducedMotion }"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerCancel"
>
<div class="showcase__track" :style="trackStyle">
<div class="showcase__track">
<article
v-for="(slide, i) in slides"
:key="slide.key"
class="showcase__slide"
:class="{ 'is-active': i === index }"
:class="{ 'is-active': i === index && !dragging }"
:style="slideStyle(i)"
>
<img v-if="slide.image" :src="slide.image" alt="" class="showcase__art" draggable="false" />
</article>
@@ -49,7 +45,7 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useAppearanceStore } from '../../stores/appearanceStore'
/** A slide from the admin slider section. Display-only — it has no destination. */
@@ -128,8 +124,7 @@ function endDrag() {
pointerId = null
dragX.value = 0
// Screen-space drag direction: dragging left (negative dx) is "next slide",
// matching what the gesture means to the person doing it.
// Screen-space: drag left (negative) → next; drag right → previous.
if (ratio <= -DRAG_COMMIT_RATIO && index.value < props.slides.length - 1) index.value++
else if (ratio >= DRAG_COMMIT_RATIO && index.value > 0) index.value--
@@ -150,14 +145,43 @@ function onPointerCancel(e: PointerEvent) {
isSwipe = false
}
const trackStyle = computed(() => {
const base = -index.value * 100
const dragPercent = trackWidth ? (dragX.value / trackWidth) * 100 : 0
return {
transform: `translate3d(${base + dragPercent}%, 0, 0)`,
transition: dragging.value ? 'none' : undefined,
/**
* Stacked crossfade: only the active slide is fully opaque unless the user is
* mid-swipe, in which case we preview the neighbour by drag progress.
*/
function slideStyle(i: number) {
const active = index.value
const reduced = appearance.isReducedMotion
if (!dragging.value || !isSwipe || !trackWidth) {
const on = i === active
return {
opacity: on ? 1 : 0,
zIndex: on ? 2 : 0,
}
}
})
const progress = Math.min(1, Math.abs(dragX.value) / trackWidth)
const towardNext = dragX.value < 0
const neighbor = towardNext ? active + 1 : active - 1
const neighborOk = neighbor >= 0 && neighbor < props.slides.length
if (i === active) {
return {
opacity: neighborOk ? 1 - progress * 0.92 : 1,
zIndex: 2,
transition: reduced ? 'none' : undefined,
}
}
if (neighborOk && i === neighbor) {
return {
opacity: progress,
zIndex: 3,
transition: 'none',
}
}
return { opacity: 0, zIndex: 0 }
}
onBeforeUnmount(() => {
if (pointerId !== null && root.value) {
@@ -183,71 +207,60 @@ onBeforeUnmount(() => {
touch-action: pan-y;
user-select: none;
-webkit-user-select: none;
// Forces its own compositing layer so `overflow: hidden` actually clips the
// translated track on Safari/iOS — without this, a sliver of the next
// slide peeks past the rounded edge because the browser composites the
// transformed child (.showcase__track) on a layer that ignores the
// parent's clip until it's promoted the same way.
transform: translateZ(0);
-webkit-transform: translateZ(0);
}
// Isolated from the page's `direction: rtl` — see the file header. Everything
// under this node uses plain LTR layout and LTR-signed transforms.
.showcase__track {
direction: ltr;
display: flex;
height: 100%;
position: relative;
width: 100%;
// A gentle, slightly-overshooting ease — softer settle than a linear/
// ease-out curve, which is what makes the swipe feel "alive" rather than
// mechanical. Slowed down a touch from the old 0.42s so the motion reads
// as a deliberate glide instead of a snap.
transition: transform 0.56s cubic-bezier(0.16, 0.86, 0.3, 1);
will-change: transform;
}
.showcase__frame.is-dragging .showcase__track {
transition: none;
height: 100%;
}
.showcase__slide {
position: relative;
flex: 0 0 100%;
width: 100%;
height: 100%;
// Clips its own image's scale-in (.showcase__art below) to this slide's own
// box, so the 1.06x zoom never bleeds into the neighbouring slide.
position: absolute;
inset: 0;
overflow: hidden;
opacity: 0;
// Soft luxury crossfade — slower settle so the change never feels like a jump.
transition: opacity 1.15s cubic-bezier(0.22, 0.61, 0.36, 1);
will-change: opacity;
}
.showcase__slide.is-active {
opacity: 1;
z-index: 2;
}
.showcase__frame.is-dragging .showcase__slide {
// Live drag preview is driven by inline opacity; avoid fighting it.
transition: none;
}
.showcase__frame.is-reduced .showcase__slide {
transition: none;
}
.showcase__art {
// A plain block filling the slide, NOT an absolutely-positioned box.
// `position: absolute` + `inset` made the image's own box a second,
// independently-rounded measurement of the same area the frame already
// sizes via `aspect-ratio`; on real devices the two disagreed by a
// fraction of a pixel and the frame's dark background showed through as a
// hairline border around the artwork. Sizing it as ordinary in-flow
// content removes that second measurement entirely.
display: block;
width: 100%;
height: 100%;
object-fit: cover;
-webkit-user-drag: none;
user-select: none;
// Never settles below 1x: the "breathe in" flourish now runs from a larger
// scale down to exactly 1, so the image is always at least as big as the
// slide and can never reveal what sits behind it.
transform: scale(1.06);
transition: transform 0.56s cubic-bezier(0.16, 0.86, 0.3, 1);
// Incoming settle: slightly overscale → exact fit, then rests at 1.
transform: scale(1.03);
transition: transform 1.15s cubic-bezier(0.22, 0.61, 0.36, 1);
}
.showcase__slide.is-active .showcase__art {
transform: scale(1);
}
.showcase__frame.is-dragging .showcase__art {
.showcase__frame.is-dragging .showcase__art,
.showcase__frame.is-reduced .showcase__art {
transition: none;
transform: scale(1);
}
.showcase__dots {
@@ -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>
@@ -14,14 +14,14 @@
-->
<template>
<article class="oc" @click="emit('view', value)">
<span class="oc__mark" :style="{ background: color.background, color: color.font }" aria-hidden="true">
<span class="oc__mark" :style="{ background: color?.background, color: color?.font }" aria-hidden="true">
<Icon icon="vuesax-linear:receipt-text" size="16" />
</span>
<div class="oc__main">
<div class="oc__head">
<p class="oc__id">{{ value.orderNumber }}</p>
<span class="oc__status" :style="{ background: color.background, color: color.font }">
<h3 class="oc__id">{{ value.orderNumber }}</h3>
<span class="oc__status" :style="{ background: color?.background, color: color?.font }">
{{ statusLabel }}
</span>
</div>
@@ -41,7 +41,7 @@
<span class="oc__date">{{ jdate(value.createdAt) }}</span>
<span class="oc__more">
جزئیات
<Icon icon="vuesax-linear:arrow-left-1" size="14" />
<Icon icon="vuesax-linear:arrow-left-2" size="14" />
</span>
</div>
</div>
@@ -51,15 +51,15 @@
<script setup lang="ts">
import { computed } from 'vue'
import Icon from '../icon/Icon.vue'
import { orderStatusColor, orderStatusLabel } from '../../utils/orderStatus'
import { ORDER_STATUS_COLORS, ORDER_STATUS_LABELS } from '../../utils/orderStatus'
import { jdate } from '../../utils/filters'
import type { OrderDetail } from '../../api/client'
const props = defineProps<{ value: OrderDetail }>()
const emit = defineEmits<{ (e: 'view', value: OrderDetail): void }>()
const statusLabel = computed(() => orderStatusLabel(props.value.status))
const color = computed(() => orderStatusColor(props.value.status))
const statusLabel = computed(() => ORDER_STATUS_LABELS[props.value.status] ?? props.value.status)
const color = computed(() => ORDER_STATUS_COLORS[props.value.status])
</script>
<style scoped lang="less">
@@ -104,8 +104,7 @@ const color = computed(() => orderStatusColor(props.value.status))
.oc__id {
margin: 0;
font-size: 13.5px;
/* yekan_bakh has no 600 face — 600 silently maps to bold (700). */
font-weight: 500;
font-weight: 800;
color: @ink;
white-space: nowrap;
overflow: hidden;
@@ -114,11 +113,13 @@ const color = computed(() => orderStatusColor(props.value.status))
.oc__status {
flex: 0 0 auto;
padding: 4px 11px;
padding: 5px 12px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 500;
font-size: 11px;
font-weight: 800;
letter-spacing: -0.01em;
white-space: nowrap;
line-height: 1.2;
}
.oc__figures {
@@ -129,13 +130,13 @@ const color = computed(() => orderStatusColor(props.value.status))
.oc__figure {
font-size: 12px;
font-weight: 500;
font-weight: 700;
color: @inkSoft;
}
.oc__figure i {
font-style: normal;
font-size: 10px;
font-weight: 400;
font-weight: 600;
color: @inkFaint;
padding-inline-start: 2px;
}
@@ -158,7 +159,7 @@ const color = computed(() => orderStatusColor(props.value.status))
align-items: center;
gap: 4px;
font-size: 11.5px;
font-weight: 500;
font-weight: 700;
color: @wine;
}
</style>
@@ -18,14 +18,16 @@
<button
v-if="lookup?.imageUrl"
type="button"
class="oi__art"
aria-label="بزرگ‌نمایی تصویر"
@click="previewOpen = true"
class="oi__art oi__art--preview"
:aria-label="`پیش‌نمایش ${lookup.productTitle ?? 'تصویر'}`"
@click="openPreview"
>
<img v-lazy="lookup.imageUrl" :alt="lookup?.productTitle ?? ''" />
</button>
<div v-else class="oi__art oi__art--empty" aria-hidden="true">
<Icon icon="vuesax-linear:diamonds" size="18" />
<div v-else class="oi__art">
<div class="oi__artEmpty" aria-hidden="true">
<Icon icon="vuesax-linear:diamonds" size="18" />
</div>
</div>
<div class="oi__main">
@@ -50,20 +52,20 @@
</div>
</div>
<!-- Above the orders drawer (z-index 2000); teleported so overflow:hidden
on the drawer body cannot clip the lightbox. -->
<Teleport to="body">
<Transition name="oi-preview">
<div
v-if="previewOpen && lookup?.imageUrl"
class="oi__preview"
role="dialog"
aria-modal="true"
aria-label="پیش‌نمایش تصویر"
@click.self="previewOpen = false"
>
<button type="button" class="oi__previewClose" aria-label="بستن" @click="previewOpen = false">
<Icon icon="vuesax-linear:close-circle" size="22" />
<div v-if="previewOpen && lookup?.imageUrl" class="oi__preview" @click.self="closePreview">
<button type="button" class="oi__previewClose" aria-label="بستن" @click="closePreview">
<Icon icon="vuesax-linear:add" size="20" />
</button>
<img class="oi__previewImg" :src="lookup.imageUrl" :alt="lookup.productTitle ?? ''" />
<img
class="oi__previewImg"
:src="lookup.imageUrl"
:alt="lookup.productTitle ?? ''"
draggable="false"
/>
</div>
</Transition>
</Teleport>
@@ -71,7 +73,7 @@
</template>
<script setup lang="ts">
import { computed, ref, watch, onBeforeUnmount } from 'vue'
import { computed, onBeforeUnmount, ref } from 'vue'
import Icon from '../icon/Icon.vue'
import { orderItemLookup } from '../../composables/useOrderItemProduct'
import type { OrderItemDetail } from '../../api/client'
@@ -82,15 +84,26 @@ const props = defineProps<{ value: OrderItemDetail }>()
// nothing to wait for — the row renders complete on its first frame instead of
// filling in after a catalogue download.
const lookup = computed(() => orderItemLookup(props.value))
const previewOpen = ref(false)
let lockedBody = false
watch(previewOpen, (open) => {
document.body.style.overflow = open ? 'hidden' : ''
})
function openPreview() {
if (!lookup.value?.imageUrl) return
previewOpen.value = true
if (document.documentElement.style.overflow !== 'hidden') {
document.body.style.overflow = 'hidden'
lockedBody = true
}
}
onBeforeUnmount(() => {
if (previewOpen.value) document.body.style.overflow = ''
})
function closePreview() {
previewOpen.value = false
if (lockedBody) document.body.style.overflow = ''
lockedBody = false
}
onBeforeUnmount(closePreview)
</script>
<style scoped lang="less">
@@ -112,15 +125,10 @@ onBeforeUnmount(() => {
border-radius: 12px;
overflow: hidden;
background: @hairlineSoft;
cursor: zoom-in;
appearance: none;
}
.oi__art--empty {
display: grid;
place-items: center;
color: @inkFaint;
cursor: default;
.oi__art--preview {
cursor: zoom-in;
}
.oi__art img {
@@ -139,6 +147,65 @@ html.theme-dark .oi__art img {
border-radius: 12px;
}
.oi__preview {
position: fixed;
inset: 0;
z-index: 3000;
display: grid;
place-items: center;
padding: 56px 20px calc(24px + env(safe-area-inset-bottom, 0px));
background: rgba(10, 10, 12, 0.55);
backdrop-filter: blur(28px) saturate(140%);
}
.oi__previewClose {
position: absolute;
top: calc(14px + env(safe-area-inset-top, 0px));
inset-inline-end: 14px;
z-index: 2;
width: 38px;
height: 38px;
display: grid;
place-items: center;
border: 0;
border-radius: 12px;
cursor: pointer;
color: #fff;
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(10px);
transform: rotate(45deg);
}
.oi__previewClose:active {
transform: rotate(45deg) scale(0.92);
}
.oi__previewImg {
max-width: min(100%, 560px);
max-height: 100%;
object-fit: contain;
border-radius: 16px;
background: #fff;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.35);
}
.oi-preview-enter-active,
.oi-preview-leave-active {
transition: opacity 0.2s ease;
}
.oi-preview-enter-from,
.oi-preview-leave-to {
opacity: 0;
}
.oi__artEmpty {
width: 100%;
height: 100%;
display: grid;
place-items: center;
color: @inkFaint;
}
.oi__main {
flex: 1;
min-width: 0;
@@ -154,8 +221,7 @@ html.theme-dark .oi__art img {
.oi__title {
margin: 0;
font-size: 12.5px;
/* yekan_bakh has no 600 face — 600 silently maps to bold (700). */
font-weight: 500;
font-weight: 700;
color: @ink;
white-space: nowrap;
overflow: hidden;
@@ -177,61 +243,14 @@ html.theme-dark .oi__art img {
.oi__figure {
font-size: 12px;
font-weight: 500;
font-weight: 700;
color: @inkSoft;
}
.oi__figure i {
font-style: normal;
font-size: 10px;
font-weight: 400;
font-weight: 600;
color: @inkFaint;
padding-inline-start: 2px;
}
</style>
<style lang="less">
/* Teleported to body — unscoped so the overlay styles apply. */
.oi__preview {
position: fixed;
inset: 0;
z-index: 2100;
display: grid;
place-items: center;
padding: 28px 18px;
background: rgba(8, 8, 10, 0.88);
backdrop-filter: blur(6px);
}
.oi__previewClose {
position: absolute;
top: 14px;
inset-inline-end: 14px;
width: 40px;
height: 40px;
border: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
color: #fff;
display: grid;
place-items: center;
cursor: pointer;
}
.oi__previewImg {
max-width: min(920px, 100%);
max-height: min(80vh, 100%);
object-fit: contain;
border-radius: 14px;
background: #fff;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.35);
}
.oi-preview-enter-active,
.oi-preview-leave-active {
transition: opacity 0.18s ease;
}
.oi-preview-enter-from,
.oi-preview-leave-to {
opacity: 0;
}
</style>
@@ -2,15 +2,10 @@
Product card in the redesigned design language (components/home/*): LESS
colour tokens, BEM class names, hairline borders, wine accent.
This is the grid/reel card used everywhere outside the home screen (search,
collection, newest, favorites). It is deliberately a sibling of
components/home/ShowcaseCard.vue rather than a merge of the two: ShowcaseCard
is the home screen's editorial card (supports a `wide` 4:3 hero variant and a
badge), while this one is the catalogue workhorse and additionally surfaces
stock, which the catalogue needs and the home screen does not.
Shared catalogue card for home, search, collection, newest and favorites.
Surfaces stock (qty / isPreOrder) alongside title, weight and wage.
Replaces ProductVertical/ProductVerticalFull, whose markup was Tailwind
utility soup carried over from the legacy PWA.
Replaces ProductVertical/ProductVerticalFull and the home-only ShowcaseCard.
WHAT IS SHOWN IS REAL — same rule as the rest of the redesign. Only fields
that genuinely exist on ProductSummary today: title, the selected variant's
@@ -20,7 +15,13 @@
stored on the product.
-->
<template>
<article ref="root" class="pc" data-product-origin @click="open">
<article
ref="root"
class="pc"
:class="size ? `pc--${size}` : null"
data-product-origin
@click="open"
>
<div class="pc__art">
<img v-if="image" v-lazy="image" :alt="title" />
<div v-else class="pc__artEmpty" aria-hidden="true">
@@ -59,7 +60,11 @@ import { openProduct } from '../../composables/useProductMorph'
import { useFavoritesStore } from '../../stores/favoritesStore'
import type { ProductSummary } from '../../api/client'
const props = defineProps<{ value: ProductSummary }>()
const props = defineProps<{
value: ProductSummary
/** Masonry height variant for the image well (home featured block). */
size?: 'hero' | 'tall' | 'mid' | 'short' | 'squat' | 'square'
}>()
const favorites = useFavoritesStore()
const { image, id, title, min_gram, wage, is_exist, qty, isPreOrder } = useProduct(props.value)
@@ -97,14 +102,40 @@ const wageLabel = computed(() => {
display: flex;
align-items: center;
justify-content: center;
background: @hairlineSoft;
margin: 4px;
border-radius: 14px;
overflow: hidden;
background: #f7f7f7;
}
.pc--hero .pc__art {
aspect-ratio: 2 / 3;
}
.pc--tall .pc__art {
aspect-ratio: 3 / 4;
}
.pc--mid .pc__art {
aspect-ratio: 4 / 5;
}
.pc--square .pc__art,
.pc--short .pc__art {
aspect-ratio: 1 / 1;
}
.pc--squat .pc__art {
aspect-ratio: 6 / 5;
}
.pc__art img {
width: 100%;
max-width: 150px;
height: 100%;
max-height: 150px;
max-width: 100%;
max-height: 100%;
padding: 6px;
box-sizing: border-box;
object-fit: contain;
object-position: center;
display: block;
@@ -0,0 +1,206 @@
<!--
Full-width horizontal product row for home «تازه‌ها».
Same fields and morph open as ProductCard — only the composition changes.
-->
<template>
<article ref="root" class="pr" data-product-origin @click="open">
<div class="pr__art">
<img v-if="image" v-lazy="image" :alt="title" />
<div v-else class="pr__artEmpty" aria-hidden="true">
<Icon icon="vuesax-linear:diamonds" size="22" />
</div>
<button
type="button"
class="pr__fav"
:class="{ 'is-on': favorites.isFavorite(id) }"
:aria-label="favorites.isFavorite(id) ? 'حذف از علاقه‌مندی' : 'افزودن به علاقه‌مندی'"
@click.stop="favorites.toggle(id)"
>
<Icon :icon="favorites.isFavorite(id) ? 'vuesax-linear:heart-fill' : 'vuesax-linear:heart'" size="15" />
</button>
<span v-if="!is_exist" class="pr__flag">ناموجود</span>
<span v-else-if="isPreOrder && qty === 0" class="pr__flag pr__flag--soft">پیش‌سفارش</span>
</div>
<div class="pr__body">
<h3 class="pr__title">{{ title }}</h3>
<div class="pr__meta">
<span class="pr__weight">{{ min_gram }} گرم</span>
<span v-if="wage != null" class="pr__wage">اجرت {{ wageLabel }}٪</span>
</div>
<span class="pr__cta">مشاهده</span>
</div>
<Icon icon="vuesax-linear:arrow-left-1" size="18" class="pr__chev" aria-hidden="true" />
</article>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import Icon from '../icon/Icon.vue'
import { useProduct } from '../../composables/useProduct'
import { openProduct } from '../../composables/useProductMorph'
import { useFavoritesStore } from '../../stores/favoritesStore'
import type { ProductSummary } from '../../api/client'
const props = defineProps<{ value: ProductSummary }>()
const favorites = useFavoritesStore()
const { image, id, title, min_gram, wage, is_exist, qty, isPreOrder } = useProduct(props.value)
const root = ref<HTMLElement | null>(null)
function open() {
openProduct(props.value, root.value)
}
const wageLabel = computed(() => {
const w = wage.value
if (w == null) return ''
return w % 1 === 0 ? String(w) : w.toFixed(2)
})
</script>
<style scoped lang="less">
.pr {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 8px 12px 8px 10px;
cursor: pointer;
background: @canvas;
border: 1px solid @hairline;
border-radius: 18px;
transition: transform 0.2s ease, border-color 0.2s ease;
}
.pr:active {
transform: scale(0.99);
}
.pr__art {
position: relative;
flex: 0 0 88px;
width: 88px;
height: 88px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 14px;
overflow: hidden;
background: #f7f7f7;
}
.pr__art img {
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
padding: 4px;
box-sizing: border-box;
object-fit: contain;
object-position: center;
display: block;
}
html.theme-dark .pr__art img {
background: @photoMat;
border-radius: 10px;
}
.pr__artEmpty {
width: 100%;
height: 100%;
display: grid;
place-items: center;
color: @inkFaint;
}
.pr__fav {
position: absolute;
top: 6px;
left: 6px;
width: 26px;
height: 26px;
display: grid;
place-items: center;
border: 0;
cursor: pointer;
border-radius: 50%;
color: @inkMuted;
background: alpha(@canvas, 90%);
backdrop-filter: blur(6px);
}
.pr__fav.is-on {
color: @wine;
}
.pr__flag {
position: absolute;
top: 6px;
right: 6px;
padding: 3px 7px;
border-radius: 999px;
font-size: 9.5px;
font-weight: 700;
color: #fff;
background: @primary;
}
.pr__flag--soft {
background: @wineSoft;
}
.pr__body {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 5px;
}
.pr__title {
margin: 0;
font-size: 13.5px;
font-weight: 700;
color: @ink;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pr__meta {
display: flex;
align-items: center;
gap: 7px;
}
.pr__weight {
font-size: 12px;
font-weight: 700;
color: @inkSoft;
}
.pr__wage {
font-size: 11px;
color: @inkFaint;
padding-inline-start: 7px;
border-inline-start: 1px solid @hairline;
}
.pr__cta {
margin-top: 2px;
font-size: 11.5px;
font-weight: 700;
color: @wine;
}
.pr__chev {
flex: 0 0 auto;
color: @inkFaint;
}
</style>
@@ -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>
@@ -227,11 +228,11 @@
@click="isUserLoggedIn() ? addToCart() : (loginNotify = true)"
>
{{ is_exist ? 'افزودن به سفارش' : 'ناموجود' }}
<Icon v-if="is_exist" icon="vuesax-linear:arrow-left-1" size="17" />
<Icon v-if="is_exist" icon="vuesax-linear:arrow-left-2" size="17" />
</button>
<router-link v-else class="pd__cta pd__cta--ghost" :to="{ name: 'cart' }">
مشاهده سبد خرید
<Icon icon="vuesax-linear:arrow-left-1" size="17" />
<Icon icon="vuesax-linear:arrow-left-2" size="17" />
</router-link>
</div>
</article>
@@ -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'
@@ -486,7 +483,7 @@ const decrement = () => {
const addToCart = () => {
// The store refuses an un-orderable variant; only confirm when it accepted.
// Previously the "added to cart" toast fired either way, telling the customer
// something had been added when nothing had. Kept in step with user-app-v2.
// something had been added when nothing had.
if (!cart.addToCart(storeProduct.value)) return
toast.success('محصول با موفقیت به سبد خرید افزوده شد', { title: 'ثبت سفارش' })
}
@@ -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 {
@@ -0,0 +1,279 @@
<!--
Brand-first PWA install offer + Android / iOS guides.
Spec: docs/superpowers/specs/2026-10-15-pwa-install-prompt-design.md
-->
<template>
<van-dialog
v-model:show="visible"
class="hg-dialog hg-install-dialog"
:show-confirm-button="false"
:show-cancel-button="false"
:close-on-click-overlay="true"
@closed="onDialogClosed"
>
<div class="hg-dialog__inner hg-install">
<button type="button" class="hg-install__close" aria-label="بستن" @click="dismiss">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M6 6l12 12M18 6L6 18"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
<template v-if="mode === 'offer'">
<div class="hg-dialog__media">
<img src="/img/brand-logo.png" :alt="brand.name" />
</div>
<div class="hg-dialog__body hg-install__copy">
<h2 class="hg-install__title">نصب اپلیکیشن {{ brand.name }}</h2>
<p class="hg-install__lead">دسترسی سریع‌تر و تجربهٔ شبیه اپ واقعی</p>
</div>
<div class="hg-dialog__actions">
<button type="button" class="hg-install__cta" @click="onPrimaryCta">
{{ canNativeInstall ? 'نصب اپ' : 'مشاهده راهنما' }}
</button>
</div>
</template>
<template v-else>
<div class="hg-dialog__body hg-install__copy">
<h2 class="hg-install__title">راهنمای نصب</h2>
<ol class="hg-install__steps">
<li v-for="(step, i) in steps" :key="i" class="hg-install__step">
<span class="hg-install__step-icon" aria-hidden="true">
<svg v-if="step.icon === 'menu'" width="20" height="20" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="5" r="1.6" fill="currentColor" />
<circle cx="12" cy="12" r="1.6" fill="currentColor" />
<circle cx="12" cy="19" r="1.6" fill="currentColor" />
</svg>
<svg v-else-if="step.icon === 'share'" width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M12 3v10M8 7l4-4 4 4"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M5 14v4a2 2 0 002 2h10a2 2 0 002-2v-4"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
<svg v-else-if="step.icon === 'home'" width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M4 11.5L12 4l8 7.5V20a1 1 0 01-1 1h-5v-5H10v5H5a1 1 0 01-1-1v-8.5z"
stroke="currentColor"
stroke-width="2"
stroke-linejoin="round"
/>
</svg>
<svg v-else width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M5 12h14M13 6l6 6-6 6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
<span class="hg-install__step-text">{{ step.text }}</span>
</li>
</ol>
</div>
<div class="hg-dialog__actions">
<button type="button" class="hg-install__cta" @click="dismiss">متوجه شدم</button>
</div>
</template>
</div>
</van-dialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Dialog as VanDialog } from 'vant'
import { brand } from '../../brand'
import { usePwaInstall } from '../../composables/usePwaInstall'
const { visible, mode, platform, canNativeInstall, dismiss, onPrimaryCta, onDialogClosed } = usePwaInstall()
const androidSteps = [
{ icon: 'menu' as const, text: 'منوی مرورگر (⋮) را باز کنید' },
{ icon: 'plus' as const, text: 'گزینهٔ «نصب برنامه» یا «Add to Home screen» را بزنید' },
{ icon: 'home' as const, text: 'نصب را تأیید کنید' },
]
const iosSteps = [
{ icon: 'share' as const, text: 'دکمهٔ Share (مربع با فلش رو به بالا) را بزنید' },
{ icon: 'plus' as const, text: '«Add to Home Screen» / افزودن به صفحهٔ اصلی را انتخاب کنید' },
{ icon: 'home' as const, text: 'Add را تأیید کنید' },
]
const steps = computed(() => (platform.value === 'ios' ? iosSteps : androidSteps))
</script>
<style lang="less">
.van-dialog.hg-dialog.hg-install-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-install-dialog .van-dialog__content {
padding: 0 !important;
}
.van-dialog.hg-install-dialog .van-dialog__footer,
.van-dialog.hg-install-dialog .van-dialog__confirm,
.van-dialog.hg-install-dialog .van-dialog__cancel {
display: none !important;
}
.hg-install.hg-dialog__inner {
display: flex;
flex-direction: column;
align-items: stretch;
position: relative;
padding: 8px 0 0;
}
.hg-install .hg-dialog__media {
display: flex;
justify-content: center;
padding: 20px 20px 0;
img {
max-width: 42%;
max-height: 96px;
width: auto;
height: auto;
object-fit: contain;
}
}
.hg-install .hg-dialog__body {
padding: 10px 22px 6px;
}
.hg-install .hg-dialog__actions {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px 16px 16px;
}
.hg-install__close {
position: absolute;
top: 10px;
inset-inline-end: 10px;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
margin: 0;
padding: 0;
border: 0;
border-radius: 999px;
background: alpha(@ink, 5%);
color: @inkMuted;
cursor: pointer;
&:active {
transform: scale(0.96);
}
}
.hg-install__copy {
text-align: center;
}
.hg-install__title {
margin: 0 0 6px;
font-size: 16px;
font-weight: 700;
line-height: 1.6;
color: @ink;
}
.hg-install__lead {
margin: 0;
font-size: 13.5px;
font-weight: 500;
line-height: 1.7;
color: @inkMuted;
}
.hg-install__steps {
list-style: none;
margin: 8px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
text-align: right;
}
.hg-install__step {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
border-radius: 14px;
background: alpha(@ink, 4%);
}
.hg-install__step-icon {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 10px;
background: alpha(@primary, 12%);
color: @primary;
}
.hg-install__step-text {
flex: 1;
font-size: 13px;
font-weight: 600;
line-height: 1.7;
color: @ink;
text-align: right;
}
.hg-dialog__actions .hg-install__cta {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 46px;
padding: 0 14px;
border: 0;
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>
@@ -33,11 +33,9 @@
on its header, the × button, or the hardware/browser back
(close-on-popstate).
STRUCTURE IS SHARED, LOOK IS NOT. The template and script here are kept
identical to user-app-v2's Filters.vue; each app styles the same markup in
its own design language (this one: soft-grey sheet with the 22px top radius
the detail page's sheet uses, white hairline-bordered cards via DropDown,
@primary 14px-radius buttons and pill chips, round controls).
Soft-grey sheet with the 22px top radius the detail page's sheet uses,
white hairline-bordered cards via DropDown, @primary 14px-radius buttons
and pill chips, round controls.
Choices are a DRAFT until "اعمال فیلتر": nothing touches the filters store
while the sheet is open, so dragging the gram slider no longer re-filters and
@@ -151,40 +149,24 @@ import { Slider } from 'ant-design-vue'
import Icon from '../icon/Icon.vue'
import DropDown from '../ui/DropDown.vue'
import type { FiltersQuery } from '../../stores/filtersStore'
import type { CatalogFacets, ProductSummary } from '../../api/client'
import type { ProductSummary } from '../../api/client'
const props = defineProps<{
show: boolean
modelValue: FiltersQuery
filterKeys: string[]
/** @deprecated Prefer `facets` — scanning products only works while the whole catalogue is in memory. */
products?: ProductSummary[]
/** Brands + gram range from GET /catalog/products/facets (STUB-03). */
facets?: CatalogFacets | null
}>()
const props = defineProps<{ show: boolean; modelValue: FiltersQuery; filterKeys: string[]; products: ProductSummary[] }>()
const emit = defineEmits<{
(e: 'update:show', value: boolean): void
(e: 'update:filterKeys', value: string[]): void
}>()
const availableBrands = computed(() => {
if (props.facets?.brands?.length) return props.facets.brands
const seen = new Map<string, { id: string; title: string }>()
for (const p of props.products ?? []) {
for (const p of props.products) {
if (p.brand) seen.set(p.brand.id, p.brand)
}
return [...seen.values()]
})
const gramBounds = computed((): [number, number] => {
if (props.facets?.gramRange) {
const min = parseFloat(props.facets.gramRange.min)
const max = parseFloat(props.facets.gramRange.max)
if (Number.isFinite(min) && Number.isFinite(max) && max >= min) {
return [Math.floor(min), Math.ceil(max)]
}
}
const weights = (props.products ?? []).flatMap((p) => p.variants.map((v) => parseFloat(v.rashWeightGrams)))
const weights = props.products.flatMap((p) => p.variants.map((v) => parseFloat(v.rashWeightGrams)))
if (weights.length === 0) return [0, 100]
return [Math.floor(Math.min(...weights)), Math.ceil(Math.max(...weights))]
})
@@ -1,82 +1,252 @@
<!--
Vant PullRefresh with content locked in place — the luxury RefreshOverlay
carries all visual feedback (no page slide-down).
Shared pull-to-refresh: Vant handles the gesture; content stays put.
Full-viewport premium overlay (dark + blur) with a large centered logo.
The ring around the logo fills with pull distance, then spins while loading.
-->
<template>
<PullRefresh
v-model="model"
class="apr"
:head-height="0"
:pull-distance="PULL_DISTANCE"
v-bind="$attrs"
@change="onChange"
@refresh="emit('refresh')"
>
<template #pulling><span /></template>
<template #loosing><span /></template>
<template #loading><span /></template>
<template #success><span /></template>
<slot />
</PullRefresh>
<RefreshOverlay :show="overlayShow" :intensity="overlayIntensity" />
<div class="app-ptr">
<Teleport to="body">
<div
class="app-ptr__overlay"
aria-hidden="true"
:class="{ 'is-visible': overlayVisible, 'is-loading': isLoading }"
:style="overlayStyle"
>
<div class="app-ptr__stage" :style="stageStyle">
<div class="app-ptr__mark">
<svg
class="app-ptr__ring keep-motion"
:class="{ 'is-spinning': isLoading }"
viewBox="0 0 120 120"
aria-hidden="true"
>
<circle class="app-ptr__ringTrack" cx="60" cy="60" :r="RING_R" />
<circle
class="app-ptr__ringFill"
cx="60"
cy="60"
:r="RING_R"
:style="ringFillStyle"
/>
</svg>
<img
src="/img/brand-logo.png"
:alt="brand.name"
class="app-ptr__logo"
draggable="false"
/>
</div>
</div>
</div>
</Teleport>
<PullRefresh
class="app-ptr__vant"
:model-value="modelValue"
:disabled="disabled"
:head-height="HEAD_HEIGHT"
:pull-distance="PULL_DISTANCE"
:success-duration="0"
@update:model-value="onModelUpdate"
@refresh="emit('refresh')"
@change="onChange"
>
<template #pulling><span /></template>
<template #loosing><span /></template>
<template #loading><span /></template>
<template #success><span /></template>
<slot />
</PullRefresh>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, ref } from 'vue'
import { PullRefresh } from 'vant'
import RefreshOverlay from './RefreshOverlay.vue'
import { brand } from '../../brand'
/** Must be set explicitly — Vant defaults pullDistance to headHeight, which is 0. */
const PULL_DISTANCE = 72
// Longer travel so the ring fill is readable (short pulls used to jump to full).
const HEAD_HEIGHT = 100
const PULL_DISTANCE = 168
const RING_R = 54
const RING_C = 2 * Math.PI * RING_R
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'refresh'): void
}>()
const model = computed({
get: () => props.modelValue,
set: (v: boolean) => emit('update:modelValue', v),
})
/** 0…1 pull progress while the finger is down; 1 while loading. */
const pullProgress = ref(0)
const overlayShow = computed(() => model.value || pullProgress.value > 0.08)
const overlayIntensity = computed(() =>
model.value ? 1 : Math.min(1, pullProgress.value),
const props = withDefaults(
defineProps<{
modelValue: boolean
disabled?: boolean
}>(),
{ disabled: false },
)
const emit = defineEmits<{
'update:modelValue': [value: boolean]
refresh: []
}>()
type PtrStatus = 'normal' | 'pulling' | 'loosing' | 'loading' | 'success'
const distance = ref(0)
const status = ref<PtrStatus>('normal')
function onChange(payload: { status: string; distance: number }) {
if (payload.status === 'loading' || model.value) {
pullProgress.value = 1
return
}
if (payload.status === 'normal') {
pullProgress.value = 0
return
}
pullProgress.value = Math.min(1, payload.distance / PULL_DISTANCE)
status.value = payload.status as PtrStatus
distance.value = payload.distance
}
watch(model, (v) => {
if (!v) pullProgress.value = 0
function onModelUpdate(value: boolean) {
emit('update:modelValue', value)
if (!value) {
distance.value = 0
status.value = 'normal'
}
}
const isLoading = computed(() => status.value === 'loading' || props.modelValue)
/** Linear pull 0→1 over PULL_DISTANCE (overlay / scale use this). */
const pullProgress = computed(() => {
if (isLoading.value) return 1
return Math.min(distance.value / PULL_DISTANCE, 1)
})
defineOptions({ inheritAttrs: false })
/**
* Ring fill eased so early pull stays visibly incomplete — linear fill on a
* short gesture looked "already full" before the eye caught up.
*/
const ringProgress = computed(() => {
if (isLoading.value) return 1
const p = pullProgress.value
return Math.pow(p, 1.55)
})
const overlayVisible = computed(() => pullProgress.value > 0.02 || isLoading.value)
const overlayStyle = computed(() => {
const p = pullProgress.value
return {
opacity: overlayVisible.value ? Math.min(0.18 + p * 0.82, 1) : 0,
pointerEvents: (isLoading.value ? 'auto' : 'none') as 'auto' | 'none',
}
})
const stageStyle = computed(() => {
const p = pullProgress.value
const scale = 0.78 + p * 0.22
return {
transform: `scale(${scale})`,
opacity: Math.min(0.45 + p * 0.55, 1),
}
})
/** Arc fill follows eased pull; full ring while loading (then CSS spins). */
const ringFillStyle = computed(() => {
const p = ringProgress.value
return {
strokeDasharray: `${RING_C}`,
strokeDashoffset: `${RING_C * (1 - p)}`,
}
})
</script>
<style scoped lang="less">
/* Keep page content still — Vant still tracks the gesture, just don't translate. */
.apr {
:deep(.van-pull-refresh__track) {
transform: none !important;
transition: none !important;
}
.app-ptr {
position: relative;
min-height: 100%;
}
:deep(.van-pull-refresh__head) {
display: none;
.app-ptr__overlay {
position: fixed;
inset: 0;
z-index: 1900;
display: grid;
place-items: center;
background: rgba(8, 8, 10, 0.58);
backdrop-filter: blur(28px) saturate(130%);
-webkit-backdrop-filter: blur(28px) saturate(130%);
transition: opacity 160ms ease-out;
}
.app-ptr__overlay:not(.is-visible) {
visibility: hidden;
}
.app-ptr__stage {
display: grid;
place-items: center;
transform-origin: center center;
will-change: transform, opacity;
transition: transform 120ms ease-out, opacity 120ms ease-out;
}
.app-ptr__mark {
position: relative;
width: 152px;
height: 152px;
display: grid;
place-items: center;
}
.app-ptr__logo {
width: 112px;
height: 112px;
object-fit: contain;
filter: drop-shadow(0 14px 32px rgba(0, 0, 0, 0.5));
z-index: 1;
}
.app-ptr__ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
transform: rotate(-90deg);
overflow: visible;
}
.app-ptr__ringTrack {
fill: none;
stroke: rgba(255, 255, 255, 0.14);
stroke-width: 3;
}
.app-ptr__ringFill {
fill: none;
stroke: rgba(255, 255, 255, 0.95);
stroke-width: 3;
stroke-linecap: round;
/* No CSS transition — fill is driven live by finger distance. */
transition: none;
}
.app-ptr__ring.is-spinning {
animation: app-ptr-spin 0.9s linear infinite;
}
.app-ptr__ring.is-spinning .app-ptr__ringFill {
/* Indeterminate feel: short arc chasing while the svg rotates. */
stroke-dasharray: 80 260 !important;
stroke-dashoffset: 0 !important;
transition: none;
}
@keyframes app-ptr-spin {
to {
transform: rotate(270deg);
}
}
/* Kill Vant's content translate — gesture + distance still update via JS. */
.app-ptr__vant {
overflow: visible !important;
}
.app-ptr :deep(.van-pull-refresh__track) {
transform: none !important;
transition: none !important;
}
.app-ptr :deep(.van-pull-refresh__head) {
display: none;
}
</style>
@@ -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 }"
@@ -73,7 +73,8 @@ const indicatorOffset = computed(() => -(nav.activeLink - 1) * 100)
inset: 6px 6px 6px auto;
width: calc((100% - 12px) / var(--count));
border-radius: 16px;
background: @wineWash;
// Slightly deeper than plain @wineWash — just a touch of @wine mixed in.
background: color-mix(in srgb, @wineWash 98%, @wine 2%);
transition: transform 0.35s cubic-bezier(0.65, 0, 0.35, 1);
will-change: transform;
}
@@ -98,7 +99,7 @@ const indicatorOffset = computed(() => -(nav.activeLink - 1) * 100)
}
.tabbar__item.is-active {
color: @wine;
color: @primary;
transform: translateY(-2px);
}
+3 -3
View File
@@ -100,10 +100,10 @@ function onBack() {
.bar__title {
margin: 0;
font-size: 13.5px;
font-weight: 600;
font-size: 14.5px;
font-weight: 800;
color: @ink;
letter-spacing: 0;
letter-spacing: -0.1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -1,134 +0,0 @@
<!--
Full-viewport luxury veil while pull-to-refresh is in flight.
Dark translucent glass + brand mark so the refresh is unmistakable.
Intensity (0…1) fades the veil with the gesture; content itself never moves.
-->
<template>
<Teleport to="body">
<div
v-show="show"
class="rfx"
:class="{ 'rfx--blocking': blocking }"
:style="{ opacity: intensity }"
role="status"
aria-live="polite"
:aria-busy="blocking"
aria-label="در حال تازه‌سازی"
>
<div class="rfx__veil" aria-hidden="true" />
<div
class="rfx__stage"
:style="{ transform: `scale(${0.92 + 0.08 * intensity})` }"
>
<img
class="rfx__logo"
src="/img/brand-logo.png"
:alt="brand.name"
width="140"
height="140"
/>
<div class="rfx__spin" aria-hidden="true">
<Loading type="spinner" size="28px" color="#fff" />
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Loading } from 'vant'
import { brand } from '../../brand'
const props = withDefaults(
defineProps<{
show: boolean
/** 0…1 — fades in with the pull; 1 while the request is in flight. */
intensity?: number
}>(),
{ intensity: 1 },
)
/** Only block touches once refresh has actually started. */
const blocking = computed(() => props.intensity >= 0.99)
</script>
<style scoped lang="less">
.rfx {
position: fixed;
inset: 0;
z-index: 4000;
display: grid;
place-items: center;
pointer-events: none;
transition: opacity 0.12s ease;
}
.rfx--blocking {
pointer-events: all;
}
.rfx__veil {
position: absolute;
inset: 0;
background: rgba(14, 16, 20, 0.56);
backdrop-filter: blur(22px) saturate(130%);
-webkit-backdrop-filter: blur(22px) saturate(130%);
}
.rfx__stage {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
padding: 28px 32px;
will-change: transform;
}
.rfx__logo {
width: 140px;
height: 140px;
object-fit: contain;
filter: drop-shadow(0 22px 44px rgba(0, 0, 0, 0.45))
drop-shadow(0 0 32px rgba(20, 22, 26, 0.3));
animation: rfx-breathe 1.8s ease-in-out infinite;
}
.rfx__spin {
display: grid;
place-items: center;
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.14);
box-shadow: 0 10px 28px -12px rgba(0, 0, 0, 0.5);
}
@keyframes rfx-breathe {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.045);
}
}
@media (prefers-reduced-motion: reduce) {
.rfx {
transition: none;
}
.rfx__veil {
backdrop-filter: none;
-webkit-backdrop-filter: none;
background: rgba(14, 16, 20, 0.78);
}
.rfx__logo {
animation: none;
filter: drop-shadow(0 12px 24px rgba(0, 0, 0, 0.4));
}
}
</style>
+40 -25
View File
@@ -7,6 +7,9 @@
// and «به‌روزرسانی» drops the service worker and every Cache Storage entry,
// then reloads — so the next load is the new release, not a precached copy.
// localStorage is left alone: sign-in, cart and settings survive the update.
//
// `available` / `updating` are module-scoped so InstallPromptModal can wait
// while the update gate is open (same truth, not a second check loop).
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
declare const __APP_BUILD__: number
@@ -23,10 +26,31 @@ const CHECK_EVERY_MS = 5 * 60 * 1000
export const runningBuild: number = typeof __APP_BUILD__ === 'number' ? __APP_BUILD__ : 0
export const runningVersion: string = typeof __APP_VERSION__ === 'string' ? __APP_VERSION__ : ''
export function useAppUpdate() {
const available = ref<VersionInfo | null>(null)
const updating = ref(false)
/** Shared so PWA install prompt can defer while the update gate is open. */
export const updateGateAvailable = ref<VersionInfo | null>(null)
const available = updateGateAvailable
const updating = ref(false)
let timer: ReturnType<typeof setInterval> | undefined
let listeners = 0
async function check() {
if (import.meta.env.DEV || !runningBuild || available.value) return
try {
const res = await fetch(`${import.meta.env.BASE_URL}version.json?t=${Date.now()}`, { cache: 'no-store' })
if (!res.ok) return
const info = (await res.json()) as VersionInfo
if (typeof info.build === 'number' && info.build > runningBuild) available.value = info
} catch {
// Offline or a deploy in progress: try again on the next tick.
}
}
const onVisible = () => {
if (document.visibilityState === 'visible') check()
}
export function useAppUpdate() {
/** What changed between the running build and the server's, newest first. */
const changes = computed(() => {
const info = available.value
@@ -34,18 +58,6 @@ export function useAppUpdate() {
return (info.history ?? []).filter((h) => h.build > runningBuild && h.build <= info.build)
})
async function check() {
if (import.meta.env.DEV || !runningBuild || available.value) return
try {
const res = await fetch(`${import.meta.env.BASE_URL}version.json?t=${Date.now()}`, { cache: 'no-store' })
if (!res.ok) return
const info = (await res.json()) as VersionInfo
if (typeof info.build === 'number' && info.build > runningBuild) available.value = info
} catch {
// Offline or a deploy in progress: try again on the next tick.
}
}
async function applyUpdate() {
updating.value = true
try {
@@ -65,19 +77,22 @@ export function useAppUpdate() {
window.location.reload()
}
let timer: ReturnType<typeof setInterval> | undefined
const onVisible = () => {
if (document.visibilityState === 'visible') check()
}
onMounted(() => {
check()
timer = setInterval(check, CHECK_EVERY_MS)
document.addEventListener('visibilitychange', onVisible)
listeners += 1
if (listeners === 1) {
check()
timer = setInterval(check, CHECK_EVERY_MS)
document.addEventListener('visibilitychange', onVisible)
}
})
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
document.removeEventListener('visibilitychange', onVisible)
listeners -= 1
if (listeners <= 0) {
listeners = 0
if (timer) clearInterval(timer)
timer = undefined
document.removeEventListener('visibilitychange', onVisible)
}
})
return { available, changes, updating, applyUpdate, check }
@@ -1,65 +0,0 @@
// Server-side catalogue paging (STUB-03 step 4 / docs/CATALOG-PAGINATION-PLAN.md).
// Replaces "download everything, slice in the browser" for list screens.
import { computed, ref } from 'vue'
import { api, type ProductQuery, type ProductSummary } from '../api/client'
export const CATALOG_PAGE_SIZE = 20
export function usePagedProducts() {
const rows = ref<ProductSummary[]>([])
const total = ref(0)
const page = ref(0)
const loading = ref(false)
const loadingMore = ref(false)
const hasMore = computed(() => rows.value.length < total.value)
/**
* @param opts.preserveRows — pull-to-refresh: keep the current list on screen
* until the new page arrives (avoids an empty flash / stuck full-page spinner).
*/
async function resetAndLoad(query: ProductQuery, opts: { preserveRows?: boolean } = {}) {
const showFullSpinner = !opts.preserveRows || rows.value.length === 0
if (showFullSpinner) loading.value = true
page.value = 1
try {
const res = await api.listProductsPaged({
...query,
page: 1,
limit: query.limit ?? CATALOG_PAGE_SIZE,
})
rows.value = res.rows
total.value = res.total
} finally {
loading.value = false
}
}
async function loadMore(query: ProductQuery) {
if (loading.value || loadingMore.value || !hasMore.value) return
loadingMore.value = true
const next = page.value + 1
try {
const res = await api.listProductsPaged({
...query,
page: next,
limit: query.limit ?? CATALOG_PAGE_SIZE,
})
page.value = next
// Append; drop accidental duplicates if the catalogue shifted between pages.
const seen = new Set(rows.value.map((p) => p.id))
rows.value = [...rows.value, ...res.rows.filter((p) => !seen.has(p.id))]
total.value = res.total
} finally {
loadingMore.value = false
}
}
function clear() {
rows.value = []
total.value = 0
page.value = 0
}
return { rows, total, page, loading, loadingMore, hasMore, resetAndLoad, loadMore, clear }
}
+2 -2
View File
@@ -2,8 +2,8 @@
// same output shape legacy's Mixins/product/index.ts `useProduct()` produced.
// The legacy card components it was originally shaped for (ProductHorizontal/
// ProductVertical/ProductVerticalFull) are gone, replaced by the redesigned
// components/product/ProductCard.vue, components/home/ShowcaseCard.vue and
// components/product/CartItem.vue — all of which consume this same contract.
// components/product/ProductCard.vue and components/product/CartItem.vue —
// both of which consume this same contract.
//
// Honest gaps vs. legacy (no backend equivalent exists yet — apps/api was not
// touched to add these):
+24 -30
View File
@@ -1,9 +1,13 @@
// Newest / list screens: true server paging (STUB-03 step 4).
// Legacy paginated against POST /get-products; the early Nest port returned the
// whole catalogue and treated `@fetch` as a no-op. This restores real pages.
import { reactive } from 'vue'
// Adapted from legacy henza_pwa/src/Mixins/getProducts.ts. Legacy paginated
// against POST /get-products (page/limit/count). The new API's GET
// /catalog/products returns the full active catalog in one flat response with
// no pagination — so `isPull` (legacy's infinite-scroll "next page" trigger)
// is a no-op here, and `totalPages` is always 0. A caller that wants new
// products at the end of the list passes `force` to refresh the cached
// catalogue instead of fabricating fake pages.
import { reactive, watch } from 'vue'
import type { ProductSummary } from '../api/client'
import { usePagedProducts, CATALOG_PAGE_SIZE } from './usePagedProducts'
import { useCatalogStore } from '../stores/catalogStore'
export interface ProductsData {
totalPages: number
@@ -12,38 +16,28 @@ export interface ProductsData {
}
export function useProducts() {
const paged = usePagedProducts()
const catalog = useCatalogStore()
const productsData: ProductsData = reactive({
totalPages: 0,
productList: [] as ProductSummary[],
productList: catalog.products,
flag: false,
})
function sync() {
productsData.productList = paged.rows.value
productsData.totalPages = Math.ceil(paged.total.value / CATALOG_PAGE_SIZE) || 0
productsData.flag = paged.total.value < 1 && !paged.loading.value
}
// A refresh from another screen (pull-to-refresh) lands here too.
watch(
() => catalog.products,
(list) => {
productsData.productList = list
productsData.flag = list.length < 1
},
)
const getProducts = async (loading: { value: boolean }, isPull = false, force = false) => {
void force
if (isPull) {
if (!paged.hasMore.value || paged.loadingMore.value) return
loading.value = true
try {
await paged.loadMore({ sort: 'newest' })
sync()
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
return
}
loading.value = true
if (isPull) return // no further pages exist server-side yet
loading.value = force || !catalog.loaded
try {
await paged.resetAndLoad({ sort: 'newest' })
sync()
productsData.productList = await catalog.load(force)
productsData.flag = productsData.productList.length < 1
} catch (e) {
console.log(e)
} finally {
@@ -51,5 +45,5 @@ export function useProducts() {
}
}
return { productsData, getProducts, hasMore: paged.hasMore }
return { productsData, getProducts }
}
@@ -0,0 +1,230 @@
// Brand-first PWA install offer (see docs/superpowers/specs/2026-10-15-pwa-install-prompt-design.md).
// Shared module state so App.vue and InstallPromptModal share one listener / one clock.
import { computed, ref, watch, type Ref } from 'vue'
import type { Router } from 'vue-router'
import { updateGateAvailable } from './useAppUpdate'
const STORAGE_KEY = 'pwaInstall'
const REOFFER_MS = 7 * 24 * 60 * 60 * 1000
const INITIAL_DELAY_MS = 4000
export type PwaGuidePlatform = 'ios' | 'android'
export type PwaInstallMode = 'offer' | 'guide'
interface Stored {
dismissedAt: number | null
installed: boolean
}
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
}
const visible = ref(false)
const mode = ref<PwaInstallMode>('offer')
const canNativeInstall = ref(false)
const platform = ref<PwaGuidePlatform>(detectPlatform())
let deferredPrompt: BeforeInstallPromptEvent | null = null
let delayTimer: ReturnType<typeof setTimeout> | undefined
let delayElapsed = false
let bootstrapped = false
let storageOk = true
/** When true, van-dialog `@closed` must not write dismissedAt (route/gate hide). */
let skipPersistOnClose = false
function isStandalone(): boolean {
if (typeof window === 'undefined') return false
const mq = window.matchMedia?.('(display-mode: standalone)')?.matches
const iosStandalone = (navigator as Navigator & { standalone?: boolean }).standalone === true
return Boolean(mq || iosStandalone)
}
function detectPlatform(): PwaGuidePlatform {
if (typeof navigator === 'undefined') return 'android'
const ua = navigator.userAgent || ''
const iOS = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
return iOS ? 'ios' : 'android'
}
function load(): Stored {
const fallback: Stored = { dismissedAt: null, installed: false }
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return fallback
const parsed = JSON.parse(raw) as Partial<Stored>
return {
dismissedAt: typeof parsed.dismissedAt === 'number' ? parsed.dismissedAt : null,
installed: parsed.installed === true,
}
} catch {
storageOk = false
return fallback
}
}
function persist(value: Stored) {
if (!storageOk) return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(value))
} catch {
storageOk = false
}
}
const stored = ref<Stored>(load())
function syncInstalledFromEnvironment() {
if (!isStandalone()) return
if (stored.value.installed) return
stored.value = { ...stored.value, installed: true }
persist(stored.value)
}
function eligibilityCore(): boolean {
if (!storageOk) return false
syncInstalledFromEnvironment()
if (stored.value.installed || isStandalone()) return false
const dismissedAt = stored.value.dismissedAt
if (dismissedAt != null && Date.now() - dismissedAt < REOFFER_MS) return false
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return false
return true
}
const shouldShowPrompt = computed(() => eligibilityCore())
function closeModal(opts?: { silent?: boolean }) {
skipPersistOnClose = opts?.silent === true
visible.value = false
mode.value = 'offer'
}
function dismiss() {
stored.value = { ...stored.value, dismissedAt: Date.now() }
persist(stored.value)
closeModal({ silent: true })
}
function markInstalled() {
stored.value = { dismissedAt: stored.value.dismissedAt, installed: true }
persist(stored.value)
deferredPrompt = null
canNativeInstall.value = false
closeModal({ silent: true })
}
/** Overlay / ESC close from van-dialog — start the 7-day clock unless silent. */
function onDialogClosed() {
if (skipPersistOnClose) {
skipPersistOnClose = false
mode.value = 'offer'
return
}
if (!stored.value.installed) {
stored.value = { ...stored.value, dismissedAt: Date.now() }
persist(stored.value)
}
mode.value = 'offer'
}
function openGuide() {
mode.value = 'guide'
}
async function promptInstall(): Promise<'accepted' | 'dismissed' | 'unavailable'> {
if (!deferredPrompt) return 'unavailable'
const event = deferredPrompt
deferredPrompt = null
canNativeInstall.value = false
try {
await event.prompt()
const { outcome } = await event.userChoice
if (outcome === 'accepted') {
markInstalled()
return 'accepted'
}
return 'dismissed'
} catch {
return 'dismissed'
}
}
async function onPrimaryCta() {
if (canNativeInstall.value && deferredPrompt) {
await promptInstall()
return
}
openGuide()
}
function tryOpen(isFullPage: boolean, updateGateOpen: boolean) {
if (!delayElapsed) return
if (isFullPage || updateGateOpen) {
if (visible.value) closeModal({ silent: true })
return
}
if (!eligibilityCore()) return
if (visible.value) return
mode.value = 'offer'
visible.value = true
}
function onBeforeInstallPrompt(e: Event) {
e.preventDefault()
deferredPrompt = e as BeforeInstallPromptEvent
canNativeInstall.value = true
}
function onAppInstalled() {
markInstalled()
}
function bootstrapAutoPrompt(router: Router, updateAvailable: Ref<unknown>) {
if (bootstrapped || typeof window === 'undefined') return
bootstrapped = true
platform.value = detectPlatform()
syncInstalledFromEnvironment()
window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt)
window.addEventListener('appinstalled', onAppInstalled)
const isFullPage = () => router.currentRoute.value.meta?.layout === 'FullPage'
const updateGateOpen = () => Boolean(updateAvailable.value)
delayTimer = setTimeout(() => {
delayElapsed = true
tryOpen(isFullPage(), updateGateOpen())
}, INITIAL_DELAY_MS)
watch(
() => [router.currentRoute.value.fullPath, router.currentRoute.value.meta?.layout, updateAvailable.value] as const,
() => tryOpen(isFullPage(), updateGateOpen()),
)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') tryOpen(isFullPage(), updateGateOpen())
})
}
export function usePwaInstall() {
function start(router: Router) {
bootstrapAutoPrompt(router, updateGateAvailable)
}
return {
visible,
mode,
platform,
canNativeInstall,
shouldShowPrompt,
start,
dismiss,
markInstalled,
openGuide,
promptInstall,
onPrimaryCta,
closeModal,
onDialogClosed,
}
}
+1 -1
View File
@@ -224,7 +224,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);
+6 -5
View File
@@ -14,7 +14,7 @@
// JS-driven motion that reads isReducedMotion (ProductMorph,
// carousel autoplay)
//
// Kept identical in user-app and user-app-v2.
// Kept identical in user-app.
import { defineStore } from 'pinia'
export type ThemeChoice = 'system' | 'light' | 'dark'
@@ -24,15 +24,16 @@ interface Stored {
reduceMotion: boolean
}
export const APPEARANCE_STORAGE_KEY = 'gold-bank:appearance'
export const APPEARANCE_STORAGE_KEY = 'hedena-gold:appearance'
const darkQuery = () => (typeof matchMedia !== 'undefined' ? matchMedia('(prefers-color-scheme: dark)') : null)
const motionQuery = () => (typeof matchMedia !== 'undefined' ? matchMedia('(prefers-reduced-motion: reduce)') : null)
function load(): Stored {
// First run: theme follows the system; motion follows the OS's own
// "reduce motion" accessibility setting.
const fallback: Stored = { theme: 'system', reduceMotion: !!motionQuery()?.matches }
// First run: light theme by default; motion follows the OS's own
// "reduce motion" accessibility setting. Users can still pick system/dark
// in Appearance settings.
const fallback: Stored = { theme: 'light', reduceMotion: !!motionQuery()?.matches }
try {
const raw = localStorage.getItem(APPEARANCE_STORAGE_KEY)
if (!raw) return fallback
+2 -18
View File
@@ -32,28 +32,12 @@ export const useCatalogStore = defineStore('catalog', () => {
// reason to keep loading the full catalogue.
async function load(force = false): Promise<ProductSummary[]> {
// Prefer a bounded page when a caller still uses the unpaged helper — the
// real catalogue reaches ~16k rows (STUB-03). Screens that need filters or
// infinite scroll should call api.listProductsPaged directly.
const list = await cache.fetch(
KEYS.catalog,
async () => {
const page = await api.listProductsPaged({ page: 1, limit: 100, sort: 'newest' })
return page.rows
},
{ force },
)
const list = await cache.fetch(KEYS.catalog, api.listProducts, { force })
products.value = list
loaded.value = true
return list
}
/** Replace the in-memory slice (home rails, after a paged fetch). */
function replace(list: ProductSummary[]) {
products.value = list
loaded.value = true
}
function byId(id: string): ProductSummary | null {
return products.value.find((p) => p.id === id) ?? null
}
@@ -63,5 +47,5 @@ export const useCatalogStore = defineStore('catalog', () => {
cache.invalidate(KEYS.catalog)
}
return { products, loaded, categories, load, replace, byId, invalidate }
return { products, loaded, categories, load, byId, invalidate }
})
+22 -8
View File
@@ -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.
-16
View File
@@ -20,19 +20,3 @@ export function jdate(val: string | undefined | null): string {
}
return val as any
}
/** Jalali date + time for status timelines and similar detail surfaces. */
export function jdatetime(val: string | undefined | null): string {
if (val) {
return moment(val).format('jYYYY/jMM/jDD HH:mm')
}
return val as any
}
/** Compact timeline stamp — e.g. «25 مهر، 11:19» (reference card style). */
export function jtimelineWhen(val: string | undefined | null): string {
if (val) {
return moment(val).format('jD jMMMM، HH:mm')
}
return val as any
}
+82 -14
View File
@@ -90,21 +90,29 @@
</div>
</div>
<!-- Per-item note -->
<!-- Per-item note — floating island, same language as the tab bar -->
<Drawer
v-model:visible="cartData.showPopUp"
placement="bottom"
v-if="cartData.tempRow?.product"
:closable="false"
height="300px"
height="auto"
wrap-class-name="cart-note-island"
class="rtl"
:bodyStyle="{ padding: 0 }"
:body-style="{ padding: 0 }"
:drawer-style="{ background: 'transparent', boxShadow: 'none' }"
>
<div class="sheet">
<div class="sheet__handle" aria-hidden="true" />
<h3 class="sheet__title">توضیح برای {{ cartData.tempRow.product.title }}</h3>
<Form :model="formModel" name="noteForm" layout="vertical" autocomplete="off" @finish="setNote">
<FormItem has-feedback name="note">
<Textarea v-model:value="formModel.note" :rows="4" placeholder="مثلاً سایز یا جزئیات سفارش" />
<FormItem has-feedback name="note" class="sheet__field">
<Textarea
v-model:value="formModel.note"
:rows="3"
placeholder="مثلاً سایز یا جزئیات سفارش"
class="sheet__input"
/>
</FormItem>
<button type="submit" class="sheet__cta">ثبت توضیحات</button>
</Form>
@@ -124,7 +132,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>
@@ -143,7 +151,7 @@ import { useRouter } from 'vue-router'
import { Drawer, Form, FormItem, Textarea } from 'ant-design-vue'
import AppPullRefresh from '../components/ui/AppPullRefresh.vue'
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'
@@ -206,6 +214,9 @@ const submitOrder = async () => {
// Ignored by the server, which takes the customer from the token; still
// sent so older builds keep validating. Removed in Phase 2.
customerId: session.customerId ?? undefined,
// Wholesale (عمده) — same as legacy mobile; server also forces this for
// customer placements so admin omde list shows the order.
channel: 'WHOLESALE',
items: cart.items.map((item) => {
const variant = cart.variant(item)
return {
@@ -231,11 +242,8 @@ const submitOrder = async () => {
}
async function onRefresh() {
try {
await cart.init(true)
} finally {
refreshing.value = false
}
await cart.init(true)
refreshing.value = false
}
onMounted(async () => {
@@ -413,14 +421,31 @@ onMounted(async () => {
}
.sheet {
padding: 18px 16px 22px;
padding: 10px 16px 14px;
background: @canvas;
border: 1px solid @hairline;
border-radius: 22px;
box-shadow: 0 10px 28px -12px rgba(20, 22, 26, 0.18);
}
.sheet__handle {
width: 36px;
height: 4px;
margin: 0 auto 12px;
border-radius: 999px;
background: @hairline;
}
.sheet__title {
margin: 0 0 14px;
margin: 0 0 10px;
font-size: 13.5px;
font-weight: 800;
color: @ink;
line-height: 1.5;
}
.sheet__field {
margin-bottom: 12px;
}
.sheet__cta {
@@ -434,5 +459,48 @@ onMounted(async () => {
font-weight: 700;
color: #fff;
background: @primary;
transition: transform 0.15s ease;
}
.sheet__cta:active {
transform: scale(0.98);
}
</style>
<!-- Drawer teleports to body — island chrome must live outside scoped CSS. -->
<style lang="less">
.cart-note-island.ant-drawer-bottom .ant-drawer-content-wrapper {
left: 14px !important;
right: 14px !important;
bottom: calc(12px + env(safe-area-inset-bottom, 0px)) !important;
width: auto !important;
border-radius: 22px;
box-shadow: none;
}
.cart-note-island .ant-drawer-content {
background: transparent;
box-shadow: none;
}
.cart-note-island .ant-drawer-body {
padding: 0 !important;
background: transparent;
}
.cart-note-island .sheet__input.ant-input {
border-radius: 14px;
border-color: @hairline;
background: @hairlineSoft;
font-family: inherit;
font-size: 13px;
resize: none;
box-shadow: none;
}
.cart-note-island .sheet__input.ant-input:focus,
.cart-note-island .sheet__input.ant-input:hover {
border-color: alpha(@primary, 45%);
background: @canvas;
}
</style>
+111 -98
View File
@@ -4,11 +4,10 @@
Layout intent — shop first, account second:
1. a slim brand bar with search + cart
2. the live 18k rate strip (real: GET /pricing/latest)
3. an editorial showcase hero built from real categories
3. spring card carousel hero (admin slider images)
4. the category rail (real: derived from GET /catalog/products)
5. "برگزیده" — a wide feature card + a two-column grid
6. "تازه‌ها" — a horizontal rail
7. the account block (real: GET /customers/:id when signed in)
5. "برگزیده" — staggered 2-col masonry (wireframe: unequal card heights, flush bottoms)
6. "تازه‌ها" — full-width horizontal ProductRow list
Data honesty, carried over from the previous version deliberately:
- the API exposes no curated home payload, no promotions and no per-product
@@ -36,10 +35,10 @@
</header>
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="home__body" :class="{ 'is-revealed': revealed }">
<container class="home__body" :class="{ 'is-revealed': revealed }">
<RateStrip :quote="goldPrice" class="home__rate reveal" :style="revealDelay(0)" />
<ShowcaseSlider
<Carousel
v-if="slides.length"
:slides="slides"
class="home__hero reveal"
@@ -55,17 +54,30 @@
@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">
<ProductCard
v-for="cell in masonryCols.left"
:key="cell.item.id"
:value="cell.item"
:size="cell.size"
/>
</div>
<div class="home__masonryCol">
<ProductCard
v-for="cell in masonryCols.right"
:key="cell.item.id"
:value="cell.item"
:size="cell.size"
/>
</div>
</div>
</section>
@@ -76,14 +88,8 @@
action-label="مشاهده همه"
@action="goNewest"
/>
<div class="home__reel no-scrollbar">
<ShowcaseCard
v-for="item in newestProducts"
:key="item.id"
:value="item"
class="home__reelItem"
@open="openProduct"
/>
<div class="home__rows">
<ProductRow v-for="item in newestProducts" :key="item.id" :value="item" />
</div>
</section>
@@ -92,20 +98,6 @@
<p>هنوز محصولی در مجموعه ثبت نشده است.</p>
</section>
<div class="home__trust reveal" :style="revealDelay(5)">
<div class="home__trustItem">
<Icon icon="vuesax-linear:shield-tick" size="18" />
<span>خرید امن</span>
</div>
<div class="home__trustItem">
<Icon icon="vuesax-linear:verify" size="18" />
<span>اصالت کالا</span>
</div>
<div class="home__trustItem">
<Icon icon="vuesax-linear:box-tick" size="18" />
<span>ارسال بیمه‌شده</span>
</div>
</div>
</container>
</AppPullRefresh>
</div>
@@ -117,19 +109,19 @@ import { computed, onActivated, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import AppPullRefresh from '../components/ui/AppPullRefresh.vue'
import Icon from '../components/icon/Icon.vue'
import { openProduct as openProductMorph } from '../composables/useProductMorph'
import RateStrip from '../components/home/RateStrip.vue'
import ShowcaseSlider from '../components/home/ShowcaseSlider.vue'
import type { ShowcaseSlide } from '../components/home/ShowcaseSlider.vue'
import Carousel from '../components/home/Carousel.vue'
import CategoryRail from '../components/home/CategoryRail.vue'
import ShowcaseCard from '../components/home/ShowcaseCard.vue'
import ProductCard from '../components/product/ProductCard.vue'
import ProductRow from '../components/product/ProductRow.vue'
import SectionHead from '../components/home/SectionHead.vue'
import { useConfigStore } from '../stores/configStore'
import type { HomeCategory } from '../stores/configStore'
import { useCartStore } from '../stores/cartStore'
import { useFiltersStore } from '../stores/filtersStore'
import { useReveal } from '../composables/useReveal'
import { api, type AdminSlide, type GoldPriceQuote, type ProductSummary } from '../api/client'
import type { AdminSlide, GoldPriceQuote, ProductSummary } from '../api/client'
import { useCachedReads } from '../api/cachedReads'
import { useCatalogStore } from '../stores/catalogStore'
import { KEYS } from '../stores/queryCache'
@@ -156,9 +148,55 @@ 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 — matches the staggered wireframe: two equal columns,
* unequal card heights (seams don't line up), both columns end flush.
*
* Height budget (art+body): short 1.15, mid 1.30, tall 1.40, hero 1.55.
* Left short+hero+tall+mid = 5.40
* Right hero+short+mid+tall = 5.40 → same baseline, no empty well.
* Container is LTR so left/right stay stable; card text stays RTL.
*/
/**
* Even count only — an odd leftover sits alone under one column and leaves
* a white well beside it (the orphan the owner asked to drop).
*/
const featuredProducts = computed(() => {
const items = products.value.slice(0, 8)
return items.length % 2 === 1 ? items.slice(0, -1) : items
})
/** Last 10 in catalogue order, newest first in the row list. */
const newestProducts = computed(() => products.value.slice(-10).reverse())
type MasonrySize = 'hero' | 'tall' | 'mid' | 'short' | 'squat'
type MasonryCol = 'left' | 'right'
const TILES: ReadonlyArray<{ col: MasonryCol; size: MasonrySize }> = [
{ col: 'left', size: 'short' },
{ col: 'right', size: 'hero' },
{ col: 'left', size: 'hero' },
{ col: 'right', size: 'short' },
{ col: 'left', size: 'tall' },
{ col: 'right', size: 'mid' },
{ col: 'left', size: 'mid' },
{ col: 'right', size: 'tall' },
]
const masonryCols = computed(() => {
const items = featuredProducts.value
const left: { item: ProductSummary; size: MasonrySize }[] = []
const right: { item: ProductSummary; size: MasonrySize }[] = []
items.forEach((item, i) => {
const tile = TILES[i] ?? {
col: (i % 2 === 0 ? 'left' : 'right') as MasonryCol,
size: 'mid' as MasonrySize,
}
if (tile.col === 'left') left.push({ item, size: tile.size })
else right.push({ item, size: tile.size })
})
return { left, right }
})
// The hero shows ONLY what the admin uploaded in the slider section
// (manage/Const.vue "اسلایدر موبایل"). It used to fall back to slides built
@@ -170,12 +208,6 @@ const slides = computed<ShowcaseSlide[]>(() =>
.map((s, i) => ({ key: `${i}-${s.image}`, image: s.image })),
)
// Opens as a morph out of the tapped card (ProductMorph.vue, mounted in
// App.vue) — the card is found from the tap, so the emit chain stays as is.
function openProduct(product: ProductSummary) {
openProductMorph(product)
}
function onCategory(cat: HomeCategory) {
filters.query.cats = [cat.id]
router.push({ name: 'search' })
@@ -191,14 +223,15 @@ function goNewest() {
// cached until pull-to-refresh asks the server again.
async function loadData(force = false) {
const [catalogResult, catsResult, priceResult, slidesResult] = await Promise.allSettled([
// STUB-03: rails need a small newest page. Pull-to-refresh must drop the
// cached catalogue first (same rule as before paging) or the screen keeps
// showing the previous slice while gold/slides refresh.
(async () => {
if (force) catalog.invalidate()
const page = await api.listProductsPaged({ page: 1, limit: 13, sort: 'newest' })
catalog.replace(page.rows)
})(),
// The home rails read `catalog.products`, so the catalogue is loaded
// explicitly. It used to come in as a side effect of `memory.init()`, which
// now reads the small facets endpoint instead of the whole catalogue — so
// without this call the home screen would render empty rails.
//
// Still the unpaged list: the rails slice it locally («جدیدترین», per
// category). Moving them onto paged reads is step 4 of
// docs/CATALOG-PAGINATION-PLAN.md.
catalog.load(force),
memory.init(force),
reads.goldPrice({ force }),
reads.homeSlider({ force }),
@@ -213,13 +246,8 @@ async function loadData(force = false) {
}
async function onRefresh() {
try {
await loadData(true)
} finally {
// Always release Vant's PullRefresh, even when a request fails — otherwise
// the spinner sticks until a full reload (same bug Dashboard already fixed).
refreshing.value = false
}
await loadData(true)
refreshing.value = false
}
onMounted(async () => {
@@ -275,7 +303,7 @@ onActivated(async () => {
.home__barInner {
max-width: 768px;
margin: 0 auto;
padding: 11px 16px;
padding: 8px 16px;
display: flex;
align-items: center;
justify-content: space-between;
@@ -283,7 +311,8 @@ onActivated(async () => {
}
.home__logo {
height: 21px;
height: 52px;
width: 52px;
object-fit: contain;
}
@@ -317,7 +346,7 @@ onActivated(async () => {
}
.home__body {
padding-top: 62px;
padding-top: 72px;
padding-bottom: 90px;
}
@@ -337,25 +366,28 @@ onActivated(async () => {
margin-top: 28px;
}
.home__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 11px;
margin-top: 11px;
}
.home__reel {
/* LTR columns so left/right placement is stable under a page-level RTL. */
.home__masonry {
display: flex;
flex-direction: row;
direction: ltr;
align-items: flex-start;
gap: 11px;
overflow-x: auto;
margin: 0 -16px;
padding: 0 16px;
scroll-snap-type: x proximity;
}
.home__reelItem {
flex: 0 0 154px;
scroll-snap-align: start;
.home__masonryCol {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
gap: 11px;
direction: rtl;
}
.home__rows {
display: flex;
flex-direction: column;
gap: 10px;
}
.home__empty {
@@ -374,23 +406,4 @@ onActivated(async () => {
font-size: 12.5px;
}
.home__trust {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 9px;
margin-top: 26px;
}
.home__trustItem {
display: flex;
flex-direction: column;
align-items: center;
gap: 7px;
padding: 14px 6px;
border-radius: 14px;
background: @hairlineSoft;
color: @inkMuted;
font-size: 11px;
font-weight: 600;
}
</style>
+27 -7
View File
@@ -1,9 +1,12 @@
<!--
Ported from legacy henza_pwa/src/views/newest.vue, restyled into the
redesigned design language (see components/home/* and HomeView).
See useProducts.ts for the pagination-simplification note.
STUB-03 step 4: ScrollFetchSearch @fetch asks the server for the next page
(sort=newest). UI chrome unchanged.
Data wiring is untouched: the same useProducts() composable, the same
pull-to-fetch pagination, and the same ScrollFetchSearch infinite scroll.
Only the chrome and the card change — AppHeader -> PageBar, and the
Tailwind-utility product rows -> the shared ProductCard grid.
-->
<template>
<div class="newest">
@@ -21,7 +24,7 @@
<SectionHead title="تازه‌ها" subtitle="آخرین افزوده‌های مجموعه" />
<div v-if="productsData.productList.length" class="newest__grid">
<ProductCard v-for="item in productsData.productList" :key="item.id" :value="item" />
<ProductCard v-for="item in visibleProducts" :key="item.id" :value="item" />
</div>
</container>
</ScrollFetchSearch>
@@ -30,7 +33,7 @@
</template>
<script setup lang="ts">
import { onBeforeMount, ref } from 'vue'
import { computed, onBeforeMount, ref } from 'vue'
import PageBar from '../components/ui/PageBar.vue'
import Spinner from '../components/Spinner.vue'
import SectionHead from '../components/home/SectionHead.vue'
@@ -38,18 +41,35 @@ import ProductCard from '../components/product/ProductCard.vue'
import ScrollFetchSearch from '../components/ui/ScrollFetchSearch.vue'
import { useProducts } from '../composables/useProducts'
const { productsData, getProducts, hasMore } = useProducts()
const { productsData, getProducts } = useProducts()
const loadingPage = ref(false)
const loadingPull = ref(false)
// ── Incremental rendering ────────────────────────────────────────────────
// useProducts() accumulates the fetched list, and this port fetches the whole
// (small) catalogue rather than truly paging the server. Mounting every card
// at once puts hundreds of images on screen, so render a window and grow it
// each time ScrollFetchSearch reports the user reached the end — which is the
// same @fetch signal that used to drive pagination.
const PAGE_SIZE = 20
const visibleCount = ref(PAGE_SIZE)
const visibleProducts = computed(() => productsData.productList.slice(0, visibleCount.value))
onBeforeMount(async () => {
await getProducts(loadingPage, false)
})
const pull = async () => {
if (loadingPull.value || !hasMore.value) return
await getProducts(loadingPull, true)
// Reveal more of what is already loaded first; only go back to the server
// once the whole fetched list is on screen. Reaching the end is how this
// page asks for new products: the cached catalogue is refreshed.
if (visibleCount.value < productsData.productList.length) {
visibleCount.value += PAGE_SIZE
return
}
if (loadingPull.value) return
await getProducts(loadingPull, false, true)
}
</script>
@@ -1,9 +1,15 @@
<!--
Ported from legacy henza_pwa/src/views/search/collection/index.vue, restyled
into the redesigned design language.
into the redesigned design language (see components/home/* and HomeView).
STUB-03 step 4: products for this collection come from
GET /catalog/products?collectionId=&page=&limit= — not a full-catalogue scan.
Filters the one flat product list client-side by collection id (route
param) — no server-side ciny_id query param exists; consistent with the
rest of this port's "small catalog, client-side filtering" approach. That
logic is carried over unchanged.
The view no longer owns a Drawer: ProductCard opens the product detail
itself, the same way it does on every other catalogue screen, so the
detail behaviour is identical everywhere instead of re-implemented here.
-->
<template>
<div class="coll">
@@ -13,15 +19,11 @@
<container class="coll__body">
<SectionHead title="محصولات این مجموعه" subtitle="برای دیدن جزئیات، روی محصول ضربه بزنید" />
<div v-if="rows.length" class="coll__grid">
<ProductCard v-for="item in rows" :key="item.id" :value="item" />
<div v-if="collectionProducts.length" class="coll__grid">
<ProductCard v-for="item in collectionProducts" :key="item.id" :value="item" />
</div>
<div v-if="hasMore || loadingMore" ref="sentinel" class="coll__more">
<Spinner :spinning="true" :indicator="true" :fixed="false" tip="" cls="my-[10px]" />
</div>
<section v-else-if="!loadingPage && !rows.length" class="coll__empty">
<section v-else-if="!loadingPage" class="coll__empty">
<Icon icon="vuesax-linear:box" size="26" />
<p>در این مجموعه محصولی ثبت نشده است.</p>
</section>
@@ -31,51 +33,37 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import ProductCard from '../../components/product/ProductCard.vue'
import { usePagedProducts } from '../../composables/usePagedProducts'
import { useCatalogStore } from '../../stores/catalogStore'
const route = useRoute()
const { rows, loading, loadingMore, hasMore, resetAndLoad, loadMore } = usePagedProducts()
const catalog = useCatalogStore()
const loadingPage = computed(() => loading.value && rows.value.length === 0)
const loadingPage = ref(false)
const allProducts = computed(() => catalog.products)
// legacy passed the collection's title through as ?page=<title>; kept, with a
// neutral fallback so the bar is never blank on a direct link.
const collectionTitle = computed(() => (route.query.page as string) || 'مجموعه')
const collectionId = computed(() => String(route.params.id ?? ''))
const sentinel = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | undefined
function query() {
return { collectionId: collectionId.value, sort: 'newest' as const }
}
function mountObserver() {
observer?.disconnect()
if (!sentinel.value) return
observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting) && hasMore.value) {
void loadMore(query())
}
},
{ rootMargin: '400px' },
)
observer.observe(sentinel.value)
}
watch(sentinel, mountObserver)
onBeforeUnmount(() => observer?.disconnect())
const collectionProducts = computed(() =>
allProducts.value.filter((p) => p.collections.some((c) => c.collectionId === route.params.id)),
)
onMounted(async () => {
loadingPage.value = !catalog.loaded
try {
await resetAndLoad(query())
await catalog.load()
} catch (e) {
console.log(e)
} finally {
loadingPage.value = false
}
})
</script>
@@ -97,12 +85,6 @@ onMounted(async () => {
gap: 11px;
}
.coll__more {
display: flex;
justify-content: center;
padding: 18px 0 4px;
}
.coll__empty {
margin-top: 8px;
padding: 34px 20px;
+110 -87
View File
@@ -2,9 +2,15 @@
Ported from legacy henza_pwa/src/views/search/searchPage.vue, restyled into
the redesigned design language (see components/home/* and HomeView).
STUB-03 step 4: filtering and paging are server-side (GET /catalog/products
with page/limit/search/…). Facets drive the chip row and the Filters sheet.
UI chrome is unchanged — only the data path moved.
All filtering still happens client-side against the one flat product list
(no server-side query params exist for any of this — consistent with
NewestView's own "single fetch" adaptation from an earlier batch). The
filter logic, the Filters sheet, the category chips and the collection
tiles are all carried over unchanged; only the chrome and the cards change.
The collection tiles keep their original destination (/collection/:id with
the title in ?page=), and are shown above the results exactly as the legacy
ProductList did on this route.
-->
<template>
<div class="search">
@@ -56,8 +62,8 @@
-->
<div>
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="search__body">
<div v-if="loadingPage" class="search__skeleton" aria-hidden="true">
<container class="search__body">
<div v-if="loadingPage && !allProducts.length" class="search__skeleton" aria-hidden="true">
<div class="search__skHead">
<span class="skeleton search__skLine search__skLine--title" />
<span class="skeleton search__skLine search__skLine--sub" />
@@ -86,21 +92,23 @@
</button>
</div>
<SectionHead
v-if="rows.length"
title="نتایج"
:subtitle="`${total} محصول`"
/>
<div v-if="rows.length" class="search__grid">
<ProductCard v-for="item in rows" :key="item.id" :value="item" />
<div v-if="filteredProducts.length" class="search__grid">
<ProductCard v-for="item in visibleProducts" :key="item.id" :value="item" />
</div>
<div v-if="hasMore || loadingMore" ref="sentinel" class="search__more">
<!-- The catalogue is fetched in one request (no server-side paging
exists), but rendering all of it at once mounts hundreds of cards
and images on a phone. Render a window and grow it as the user
reaches the end — the same incremental behaviour the previous
ScrollFetchSearch-based list had. -->
<div v-if="hasMore" ref="sentinel" class="search__more">
<Spinner :spinning="true" :indicator="true" :fixed="false" tip="" cls="my-[10px]" />
</div>
<section v-if="!rows.length && !loadingPage" class="search__empty">
<!-- Its own condition, not a v-else of the sentinel above: chained to
it, the box also showed under the results whenever they all fit
in the first window. -->
<section v-if="!filteredProducts.length && !loadingPage" class="search__empty">
<Icon icon="vuesax-linear:search-normal-1" size="26" />
<p>محصولی با این مشخصات پیدا نشد.</p>
</section>
@@ -108,11 +116,13 @@
</AppPullRefresh>
</div>
<!-- A bottom sheet over a see-through backdrop (teleported to <body>);
see Filters.vue. The result list stays in place underneath. -->
<Filters
v-model:show="showFilter"
v-model:filterKeys="filterKeys"
v-model="filters.query"
:facets="facets"
:products="allProducts"
/>
</div>
</template>
@@ -125,60 +135,107 @@ import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import SearchInput from '../../components/search/SearchInput.vue'
import Filters from '../../components/search/Filters.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import ProductCard from '../../components/product/ProductCard.vue'
import { api, type CatalogFacets, type ProductQuery } from '../../api/client'
import { useFiltersStore } from '../../stores/filtersStore'
import { usePagedProducts } from '../../composables/usePagedProducts'
import { useCatalogStore } from '../../stores/catalogStore'
const filters = useFiltersStore()
const { rows, total, loading, loadingMore, hasMore, resetAndLoad, loadMore } = usePagedProducts()
const catalog = useCatalogStore()
const loadingPage = computed(() => loading.value && rows.value.length === 0)
// Starts true on a cold start: the first render happens before onMounted kicks
// off the fetch, and with false the "nothing found" box flashed up for a frame
// before the loading state replaced it. With the catalogue cached it renders at once.
const loadingPage = ref(!catalog.loaded)
// Two rows of the two-column grid — enough to fill the screen under the bar.
const SKELETON_CARDS = 6
const refreshing = ref(false)
const allProducts = computed(() => catalog.products)
const showFilter = ref(false)
const filterKeys = ref<string[]>([])
const facets = ref<CatalogFacets | null>(null)
const availableCategories = computed(() => facets.value?.categories ?? [])
const availableCollections = computed(() => facets.value?.collections ?? [])
const availableCategories = computed(() => {
const seen = new Map<string, { id: string; title: string }>()
for (const p of allProducts.value) {
if (p.category) seen.set(p.category.id, p.category)
}
return [...seen.values()]
})
function toServerQuery(): ProductQuery {
const availableCollections = computed(() => {
const seen = new Map<string, { id: string; title: string }>()
for (const p of allProducts.value) {
for (const c of p.collections) seen.set(c.collection.id, { id: c.collection.id, title: c.collection.title })
}
return [...seen.values()]
})
const filteredProducts = computed(() => {
let list = allProducts.value
const q = filters.query
const out: ProductQuery = { sort: 'newest' }
if (q.text?.trim()) out.search = q.text.trim()
if (q.cats?.[0]) out.categoryId = q.cats[0]
if (q.brands.length) out.brandId = q.brands.join(',')
if (q.text) list = list.filter((p) => p.title.includes(q.text!))
if (q.cats && q.cats.length > 0) list = list.filter((p) => p.category && q.cats!.includes(p.category.id))
if (q.brands.length > 0) list = list.filter((p) => p.brand && q.brands.includes(p.brand.id))
if (q.limit_gram.length === 2) {
out.minGram = q.limit_gram[0]
out.maxGram = q.limit_gram[1]
list = list.filter((p) =>
p.variants.some(
(v) => parseFloat(v.rashWeightGrams) >= q.limit_gram[0] && parseFloat(v.rashWeightGrams) <= q.limit_gram[1],
),
)
}
return out
return list
})
// ── Incremental rendering ────────────────────────────────────────────────
// The whole catalogue arrives in one request (no server-side paging exists),
// so the filtering above is instant — but mounting every matching card at once
// means hundreds of cards and images on a phone. Render a window instead and
// grow it when the sentinel at the end scrolls into view.
const PAGE_SIZE = 20
const visibleCount = ref(PAGE_SIZE)
const visibleProducts = computed(() => filteredProducts.value.slice(0, visibleCount.value))
const hasMore = computed(() => visibleCount.value < filteredProducts.value.length)
const sentinel = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | undefined
function mountObserver() {
observer?.disconnect()
if (!sentinel.value) return
observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting) && hasMore.value) {
visibleCount.value += PAGE_SIZE
}
},
{ rootMargin: '400px' },
)
observer.observe(sentinel.value)
}
async function loadFacets() {
try {
facets.value = await api.getCatalogFacets()
} catch (e) {
console.log(e)
}
}
// Any change to the filters restarts the window from the top.
watch(filteredProducts, () => {
visibleCount.value = PAGE_SIZE
})
async function reload(opts: { preserveRows?: boolean } = {}) {
// The sentinel is v-if'd, so re-attach whenever it appears or is replaced.
watch(sentinel, mountObserver)
onBeforeUnmount(() => observer?.disconnect())
async function loadProducts(force = false) {
loadingPage.value = !catalog.loaded
try {
await resetAndLoad(toServerQuery(), opts)
await catalog.load(force)
} catch (e) {
console.log(e)
} finally {
loadingPage.value = false
}
}
async function onRefresh() {
try {
await Promise.all([loadFacets(), reload({ preserveRows: true })])
} finally {
refreshing.value = false
}
await loadProducts(true)
refreshing.value = false
}
function search(value: string | undefined) {
@@ -191,45 +248,7 @@ function showFilters() {
showFilter.value = true
}
const sentinel = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | undefined
function mountObserver() {
observer?.disconnect()
if (!sentinel.value) return
observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting) && hasMore.value) {
void loadMore(toServerQuery())
}
},
{ rootMargin: '400px' },
)
observer.observe(sentinel.value)
}
watch(sentinel, mountObserver)
onBeforeUnmount(() => observer?.disconnect())
// Debounce text; category / brand / gram apply immediately.
let textTimer: ReturnType<typeof setTimeout> | undefined
watch(
() => filters.query.text,
() => {
clearTimeout(textTimer)
textTimer = setTimeout(() => void reload(), 300)
},
)
watch(
() => [filters.query.cats, filters.query.brands, filters.query.limit_gram] as const,
() => void reload(),
{ deep: true },
)
onMounted(async () => {
await loadFacets()
await reload()
})
onMounted(() => loadProducts())
</script>
<style scoped lang="less">
@@ -264,20 +283,24 @@ onMounted(async () => {
.search__chip {
scroll-snap-align: start;
flex: 0 0 auto;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 7px 14px;
height: 30px;
padding: 0 14px;
border-radius: 999px;
cursor: pointer;
font-family: inherit;
font-size: 12.5px;
font-weight: 600;
line-height: 1;
white-space: nowrap;
color: @inkSoft;
background: @canvas;
border: 1px solid @hairline;
transition: all 0.2s ease;
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.15s ease;
}
.search__chip.is-on {
+181 -39
View File
@@ -49,11 +49,36 @@
</header>
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="dash__body" :class="{ 'is-revealed': revealed }">
<container class="dash__body" :class="{ 'is-revealed': revealed }">
<!-- Signed in: real position from GET /customer/me. Auth is the token —
never treat a slow/failed profile fetch as a guest session. -->
<template v-if="session.isLoggedIn && customer">
<section class="dash__balance reveal" :style="revealDelay(0)">
<!--
Incomplete profile notice — same gate as ProfileView's
"تکمیل پروفایل" button (docs/plans/2026-10-16-dashboard-incomplete-profile-banner-design.md).
-->
<section
v-if="needsProfileComplete"
class="dash__profileNudge reveal"
:style="revealDelay(0)"
>
<div class="dash__profileNudgeIcon" aria-hidden="true">
<Icon icon="vuesax-linear:info-circle" size="16" />
</div>
<div class="dash__profileNudgeCopy">
<h3>پروفایل شما تکمیل نشده است</h3>
<p>برای ادامه، مدارک پروفایل را بارگذاری کنید.</p>
</div>
<button
type="button"
class="dash__profileNudgeCta"
@click="router.push({ name: 'completeProfile' })"
>
تکمیل پروفایل
</button>
</section>
<section class="dash__balance reveal" :style="revealDelay(needsProfileComplete ? 1 : 0)">
<div class="dash__balanceHead">
<div>
<div class="dash__eyebrow">{{ remainText }}</div>
@@ -118,27 +143,29 @@
</section>
<!-- Nav grid -->
<section class="dash__section reveal" :style="revealDelay(1)">
<SectionHead title="دسترسی‌ها" subtitle="بخش‌های حساب شما" />
<div class="dash__grid">
<section class="dash__section reveal" :style="revealDelay(customer && needsProfileComplete ? 2 : 1)">
<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>
<!-- Live 18k rate — same strip the home screen uses -->
<section class="dash__section reveal" :style="revealDelay(2)">
<section class="dash__section reveal" :style="revealDelay(customer && needsProfileComplete ? 3 : 2)">
<SectionHead title="نرخ روز" subtitle="آخرین نرخ اعلام‌شده" />
<RateStrip :quote="goldPrice" />
</section>
@@ -147,7 +174,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>
@@ -158,7 +185,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">
@@ -183,8 +210,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'
@@ -210,17 +237,26 @@ const grams = computed(() =>
customer.value ? Math.abs(parseFloat(customer.value.goldDebtGrams)) : 0,
)
/**
* Same gate as ProfileView's "تکمیل پروفایل" CTA: show while documents have
* not been submitted (STEP_TWO_SUBMITTED) or approved (VERIFIED).
*/
const needsProfileComplete = computed(() => {
const step = customer.value?.verificationStep
return Boolean(step) && step !== 'VERIFIED' && step !== 'STEP_TWO_SUBMITTED'
})
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 },
]
/**
@@ -230,13 +266,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,
@@ -323,7 +359,7 @@ async function onRefresh() {
// D21 — the expert flag used to be fetched once, at sign-in, and a slow or
// failed whoami left it false for good (the expert entries stayed hidden until
// the next login). Ask again on every dashboard visit; a failed request keeps
// whatever the flag already is.
// whatever the flag already is. Do not drop this — «مشتریان من» depends on it.
async function syncExpertFlag() {
if (!session.isLoggedIn) return
try {
@@ -417,6 +453,68 @@ onMounted(async () => {
padding-bottom: 90px;
}
.dash__profileNudge {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-bottom: 12px;
padding: 10px 12px;
border-radius: 14px;
border: 1px solid alpha(@wine, 16%);
background: @wineWash;
}
.dash__profileNudgeIcon {
flex: 0 0 auto;
width: 32px;
height: 32px;
border-radius: 9px;
display: grid;
place-items: center;
color: @wine;
background: alpha(@wine, 10%);
}
.dash__profileNudgeCopy {
flex: 1 1 140px;
min-width: 0;
}
.dash__profileNudgeCopy h3 {
margin: 0;
font-size: 12.5px;
font-weight: 500;
color: @ink;
line-height: 1.35;
}
.dash__profileNudgeCopy p {
margin: 2px 0 0;
font-size: 11.5px;
font-weight: 400;
line-height: 1.4;
color: @inkMuted;
}
.dash__profileNudgeCta {
flex: 0 0 auto;
margin-inline-start: auto;
padding: 7px 11px;
border: 0;
border-radius: 10px;
cursor: pointer;
font-family: inherit;
font-size: 11.5px;
font-weight: 500;
color: #fff;
background: @primary;
}
.dash__profileNudgeCta:active {
transform: scale(0.96);
}
.dash__balance {
padding: 16px;
border-radius: 20px;
@@ -571,47 +669,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>
+163 -52
View File
@@ -22,19 +22,22 @@
<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>
</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 +49,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 +78,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 +97,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 +128,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 +232,93 @@ 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__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 +326,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 +404,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 +430,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 +442,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 {
+184 -59
View File
@@ -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>
+1 -1
View File
@@ -14,7 +14,7 @@ const curve = {
px: 'px',
}
const vars = {
prefix: 'tehrantaj',
prefix: 'hedena',
// headers
'font-size': curve.font + curve.px,
'h1-font-size': curve.font * 2 + curve.px,
+5 -5
View File
@@ -12,14 +12,14 @@
* in docs/BRANDING.md.
*/
export const brand = {
/** Persian name, used inside sentences: «ورود به تهران تاج». */
name: 'تهران تاج',
/** Persian name, used inside sentences: «ورود به هدناگلد». */
name: 'هدناگلد',
/** Latin name: PWA install name, alt texts, footer. */
nameEn: 'Tehran Taj',
nameEn: 'Hedena Gold',
/** Browser tab / home-screen title of the customer apps. */
appTitle: 'طلای تهران تاج',
appTitle: 'طلای هدناگلد',
/** Browser tab title of the admin panel. */
adminTitle: 'تهران تاج — پنل مدیریت',
adminTitle: 'هدناگلد — پنل مدیریت',
/** Public customer site; product links in the admin panel point here. */
siteUrl: 'https://bnk.montzar.ir',
} as const;
+5 -5
View File
@@ -1,13 +1,13 @@
# برند پروژه (تهران تاج) — چک‌لیست تغییر نام
# برند پروژه (هدناگلد) — چک‌لیست تغییر نام
نام برند **تهران تاج / Tehran Taj** است. برای عوض کردن نام دوباره **لازم نیست کل کد را بگردید**؛ فقط موارد همین فهرست.
نام برند **هدناگلد / Hedena Gold** است. برای عوض کردن نام دوباره **لازم نیست کل کد را بگردید**؛ فقط موارد همین فهرست.
## ۱. جاهایی که باید عوض شوند (فقط همین‌ها)
| چه چیزی | کجا | توضیح |
|---|---|---|
| نام فارسی، نام لاتین، عنوان تب مرورگر، آدرس سایت | [`brand.ts`](../brand.ts) در ریشهٔ پروژه | پنل ادمین، اپ مشتری و اپ مشتری v2 همه از همین یک فایل می‌خوانند: عنوان صفحه، نام نصب PWA، عنوان صفحهٔ ورود، پیام خوش‌آمد، متن‌های راهنما، لینک محصول. |
| نام برند در **همهٔ پیامک‌ها** | متغیر محیطی `BRAND_NAME` روی سرور API | خالی باشد = «تهران تاج» (پیش‌فرض در [`apps/api/src/common/brand.ts`](../apps/api/src/common/brand.ts)). بعد از تغییر فقط API را ری‌استارت کنید. |
| نام برند در **همهٔ پیامک‌ها** | متغیر محیطی `BRAND_NAME` روی سرور API | خالی باشد = «هدناگلد» (پیش‌فرض در [`apps/api/src/common/brand.ts`](../apps/api/src/common/brand.ts)). بعد از تغییر فقط API را ری‌استارت کنید. |
| لینک سایت در پیامک خوش‌آمد | متغیر محیطی `BRAND_SITE_URL` روی سرور API | پیش‌فرض فعلاً `https://bnk.montzar.ir` است (دامنهٔ زنده). |
| لوگو | فایل‌های تصویر با نام ثابت `brand-*` (پایین) | نام فایل‌ها عمومی است؛ فقط خود تصویر را با همان نام جایگزین کنید. |
@@ -35,7 +35,7 @@
| کلیدهای مرورگر مثل `gold-bank:token` | تغییر = خروج همهٔ مشتری‌ها از اپ |
| جدول `_goldbank_migrations`، نام دیتابیس `goldbank`، سرویس داکر `goldbank-api` | تغییر = خراب شدن مایگریشن و استقرار روی سرور |
| نام پکیج‌ها `@gold-bank/*` و پوشهٔ پروژه | فقط نام فنی |
| شناسهٔ برند محصول `brand-montzar` در دیتابیس | کلید اصلی است و محصولات به آن وصل‌اند؛ عنوانش به «تهران تاج طلا» تغییر کرد |
| شناسهٔ برند محصول `brand-montzar` در دیتابیس | کلید اصلی است و محصولات به آن وصل‌اند؛ عنوانش به «هدناگلد» تغییر کرد |
| پوشه‌های `henza_admin`، `henza_pwa`، `henza_server` و ارجاع‌های کد به آن‌ها | کد سیستم قدیمی، فقط برای مرجع |
| آدرس‌های `henzagold.com` در توضیحات کد | سابقهٔ سیستم قدیمی، اجرا نمی‌شوند |
| پوشهٔ `docs/` (سوابق) | سابقهٔ کار است، نه متن برنامه |
@@ -45,7 +45,7 @@
برای اطمینان از اینکه نام قدیمی جایی در متن‌های برنامه نمانده:
```bash
grep -rn "زرتاج\|Zartaj\|منتزر\|Montzar\|هنزا" apps/*/src apps/*/index.html apps/api/prisma/seed.ts brand.ts
grep -rn "تهران تاج\|Tehran Taj\|زرتاج\|Zartaj\|منتزر\|Montzar\|هنزا" apps/*/src apps/*/index.html apps/api/prisma/seed.ts brand.ts
```
خروجی خالی یعنی تمیز است.
+2 -2
View File
@@ -2,7 +2,7 @@
منبع جزئیات: [`parity/CHANGELOG.md`](parity/CHANGELOG.md)، [`parity/ISSUES.md`](parity/ISSUES.md)، [`parity/DECISIONS.md`](parity/DECISIONS.md).
این سند برای **همین ریپو (gold-bank / تهران تاج)** است تا ایرادها را verify کنیم یا باقی‌مانده‌ها را ببندیم — و در عین حال قالب تکرار برای پروژهٔ مشابه.
این سند برای **همین ریپو (gold-bank / هدناگلد)** است تا ایرادها را verify کنیم یا باقی‌مانده‌ها را ببندیم — و در عین حال قالب تکرار برای پروژهٔ مشابه.
> قبل از هر تغییر **UI سمت اپ**: از مالک (Honar) بپرس. منطق/رفتار بدون سوال؛ ظاهر نه.
> اپ مبنا برای منطق: هر دو `apps/user-app` و `apps/user-app-v2` (مگر خلافش گفته شود).
@@ -15,7 +15,7 @@
|---|---|---|
| ایرادهای امنیتی / پولی / دامنه / UX کارکردی | بله | منطق یکسان |
| فونت IRANSans (اپ + ادمین) | بله | همان فونت و wiring |
| لوگو / فاویکون / آیکن برند / wordmark | **خیر** | برند تهران تاج دست‌نخورده — [`BRANDING.md`](BRANDING.md) |
| لوگو / فاویکون / آیکن برند / wordmark | **خیر** | برند هدناگلد دست‌نخورده — [`BRANDING.md`](BRANDING.md) |
| کاروسل / hero / ShowcaseSlider | **خیر** | **اصلاً دست نزن** (حتی در verify) |
| فیچر اپ مثل PWA install، pull-refresh، incomplete-profile banner | بله | **فقط منطق/رفتار**؛ UI با زبان طراحی خودِ اپ |
| دیزاین / layout ادمین | بله | ادمین قابل‌رفرنس از همین ریپو (بدون تعویض لوگو) |
@@ -0,0 +1,31 @@
# Brand logo assets — icon-only mark
Date: 2026-10-15
## Goal
Replace all brand image assets with the new Hedena icon-only mark (gold symbol on black, no wordmark). Keep text brand name as هدنا گلد / Hedena Gold in `brand.ts`.
## Source
- Master: icon-only logo (Frame 2) — square, gold on black, no text.
- Full wordmark (Frame 1) is not used in this pass.
## Mapping
| Role | Targets |
|---|---|
| App / admin logos | `user-app` `brand-logo.png/.webp` (public + assets); admin `brand-icon.png/.webp`, `brand-horizontal.png` |
| Favicon + PWA | `favicon.ico` / `favicon.png`, `icon-192.png`, `icon-512.png` in both apps’ `public/` |
| Product no-image | `user-app` `public/img/no-items.png` and `src/assets/img/no-items.png` |
| Lazy-load error placeholder | Continues to use `brand-logo.png` via `main.ts` (no code path change) |
## Out of scope
- Renaming brand strings in `brand.ts` or SMS env
- Empty-state SVG illustrations (cart empty, etc.)
- Regenerating `dist/` / API release builds (caller rebuilds as needed)
## Approach
Overwrite fixed filenames listed in `docs/BRANDING.md`. Resize the same master for PWA/favicon sizes. No Vue template changes required if paths stay the same.
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+94
View File
@@ -0,0 +1,94 @@
/**
* Generate brand image assets from the icon-only master mark.
* Usage (from repo root): node scripts/generate-brand-icons.mjs
*
* Requires: npm install sharp to-ico (dev/one-off; or already present)
*/
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import sharp from 'sharp'
import toIco from 'to-ico'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(__dirname, '..')
const master = path.join(__dirname, 'brand-assets', 'hedena-icon.webp')
if (!fs.existsSync(master)) {
console.error('Missing master:', master)
process.exit(1)
}
async function writePng(outPath, size) {
await fs.promises.mkdir(path.dirname(outPath), { recursive: true })
await sharp(master)
.resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 1 } })
.png()
.toFile(outPath)
console.log('wrote', path.relative(root, outPath))
}
async function writeWebp(outPath, size) {
await fs.promises.mkdir(path.dirname(outPath), { recursive: true })
await sharp(master)
.resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 1 } })
.webp({ quality: 90 })
.toFile(outPath)
console.log('wrote', path.relative(root, outPath))
}
async function writeFull(outPath, format) {
await fs.promises.mkdir(path.dirname(outPath), { recursive: true })
const pipeline = sharp(master)
if (format === 'png') await pipeline.png().toFile(outPath)
else await pipeline.webp({ quality: 90 }).toFile(outPath)
console.log('wrote', path.relative(root, outPath))
}
async function writeIco(outPath, sizes = [16, 32, 48]) {
const bufs = await Promise.all(
sizes.map((s) =>
sharp(master)
.resize(s, s, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 1 } })
.png()
.toBuffer(),
),
)
const ico = await toIco(bufs)
await fs.promises.mkdir(path.dirname(outPath), { recursive: true })
await fs.promises.writeFile(outPath, ico)
console.log('wrote', path.relative(root, outPath))
}
const userPublic = path.join(root, 'apps/user-app/public')
const userAssets = path.join(root, 'apps/user-app/src/assets/img')
const adminPublic = path.join(root, 'apps/admin-panel/public')
const adminLogo = path.join(root, 'apps/admin-panel/src/assets/images/logo')
// Ensure adminPublic exists before writing no-items under it
await fs.promises.mkdir(path.join(adminPublic, 'img'), { recursive: true })
await writeFull(path.join(userPublic, 'img/brand-logo.png'), 'png')
await writeFull(path.join(userPublic, 'img/brand-logo.webp'), 'webp')
await writeFull(path.join(userAssets, 'brand-logo.png'), 'png')
// Product / missing-image placeholder — same mark
await writeFull(path.join(userPublic, 'img/no-items.png'), 'png')
await writeFull(path.join(userAssets, 'no-items.png'), 'png')
await writeFull(path.join(adminPublic, 'img/no-items.png'), 'png')
await writeFull(path.join(adminLogo, 'brand-icon.png'), 'png')
await writeFull(path.join(adminLogo, 'brand-icon.webp'), 'webp')
await writeFull(path.join(adminLogo, 'brand-horizontal.png'), 'png')
await writePng(path.join(userPublic, 'icon-192.png'), 192)
await writePng(path.join(userPublic, 'icon-512.png'), 512)
await writePng(path.join(adminPublic, 'icon-192.png'), 192)
await writePng(path.join(adminPublic, 'icon-512.png'), 512)
await writePng(path.join(adminPublic, 'favicon.png'), 32)
await writeIco(path.join(userPublic, 'favicon.ico'))
await writeIco(path.join(adminPublic, 'favicon.ico'))
await writeIco(path.join(adminLogo, 'favicon.ico'))
console.log('done')
+22 -12
View File
@@ -1,18 +1,26 @@
"""
Legacy Python generator — prefer scripts/generate-brand-icons.mjs when Node + sharp are available.
Point `src` at the icon-only master (scripts/brand-assets/hedena-icon.webp), then run from repo root.
"""
from PIL import Image
from pathlib import Path
src = Path(r"C:\Users\mahmo\.cursor\projects\c-App-gold-bank\assets\c__Users_mahmo_AppData_Roaming_Cursor_User_workspaceStorage_e98278b20f306ff9e248e1d576e8bb3f_images_mzLogoDarkmode-079331ec-aa35-4a0f-bc63-1b0a0c4215eb.webp")
src = Path(__file__).resolve().parent / "brand-assets" / "hedena-icon.webp"
img = Image.open(src).convert("RGBA")
print("source size", img.size)
root = Path(r"C:\App\gold-bank")
root = Path(__file__).resolve().parents[1]
(root / "apps/user-app/public/img").mkdir(parents=True, exist_ok=True)
(root / "apps/user-app/src/assets/img").mkdir(parents=True, exist_ok=True)
(root / "apps/admin-panel/src/assets/images/logo").mkdir(parents=True, exist_ok=True)
(root / "apps/admin-panel/public").mkdir(parents=True, exist_ok=True)
img.save(root / "apps/user-app/public/img/brand-logo.webp", "WEBP")
img.save(root / "apps/user-app/public/img/brand-logo.png", "PNG")
img.save(root / "apps/user-app/src/assets/img/brand-logo.png", "PNG")
img.save(root / "apps/user-app/public/img/no-items.png", "PNG")
img.save(root / "apps/user-app/src/assets/img/no-items.png", "PNG")
img.save(root / "apps/admin-panel/src/assets/images/logo/brand-icon.webp", "WEBP")
img.save(root / "apps/admin-panel/src/assets/images/logo/brand-icon.png", "PNG")
img.save(root / "apps/admin-panel/src/assets/images/logo/brand-horizontal.png", "PNG")
@@ -20,15 +28,17 @@ img.save(root / "apps/admin-panel/src/assets/images/logo/brand-horizontal.png",
for size, path in [
(192, root / "apps/user-app/public/icon-192.png"),
(512, root / "apps/user-app/public/icon-512.png"),
(192, root / "apps/admin-panel/public/icon-192.png"),
(512, root / "apps/admin-panel/public/icon-512.png"),
(32, root / "apps/admin-panel/public/favicon.png"),
]:
resized = img.resize((size, size), Image.Resampling.LANCZOS)
resized.save(path, "PNG")
print("wrote", path, size)
icon = Image.new("RGBA", (size, size), (0, 0, 0, 255))
fitted = img.copy()
fitted.thumbnail((size, size), Image.Resampling.LANCZOS)
x = (size - fitted.width) // 2
y = (size - fitted.height) // 2
icon.paste(fitted, (x, y), fitted)
icon.save(path, "PNG")
print("wrote", path)
favicon = img.resize((256, 256), Image.Resampling.LANCZOS)
favicon.save(root / "apps/user-app/public/favicon.ico", sizes=[(16, 16), (32, 32), (48, 48), (64, 64)])
favicon.save(
root / "apps/admin-panel/src/assets/images/logo/favicon.ico",
sizes=[(16, 16), (32, 32), (48, 48), (64, 64)],
)
print("done")
print("Note: favicon.ico is generated by scripts/generate-brand-icons.mjs (to-ico).")