Polish Hedena brand, home layout, and user-app chrome.
Switch the customer app to IRANSans, restyle home featured/newest and cart note sheet, refresh brand icons across apps, and add the PWA install prompt. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 871 B |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 87 KiB |
@@ -14,10 +14,8 @@
|
||||
and `third-color` were never real ant variables (confirmed dead in the old
|
||||
LESS build) and stay only as the CSS custom properties in utilities.less.
|
||||
|
||||
ant derives its light "primary background" steps from colorPrimary; for a
|
||||
navy this dark the generated steps are mid-greys (#8d9296, #81858a), which
|
||||
painted every selected option / tree node / picker cell grey. Those steps
|
||||
are pinned to light navy tints instead (see `themeTokens`).
|
||||
Light primary steps are pinned to blue-600 tints so selected options /
|
||||
tree nodes / picker cells stay on-brand (see `themeTokens`).
|
||||
-->
|
||||
<a-config-provider :locale="faIR" direction="rtl" :theme="{ token: themeTokens }">
|
||||
<div id="app" :class="vueAppClasses">
|
||||
@@ -43,14 +41,14 @@ import AppUpdateGate from './components/common/AppUpdateGate.vue';
|
||||
import { userName } from './utils';
|
||||
|
||||
const themeTokens = {
|
||||
colorPrimary: '#1c2d56',
|
||||
colorPrimary: '#1d4ed8',
|
||||
colorLink: '#1DA57A',
|
||||
borderRadius: 6,
|
||||
controlHeight: 36,
|
||||
colorPrimaryBg: '#eef1f8',
|
||||
colorPrimaryBgHover: '#e1e6f2',
|
||||
colorPrimaryBorder: '#c5cde0',
|
||||
colorPrimaryBorderHover: '#9ea9c6',
|
||||
colorPrimaryBg: '#eff6ff',
|
||||
colorPrimaryBgHover: '#dbeafe',
|
||||
colorPrimaryBorder: '#bfdbfe',
|
||||
colorPrimaryBorderHover: '#93c5fd',
|
||||
};
|
||||
|
||||
const layout = useLayoutStore();
|
||||
|
||||
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 14 KiB |
@@ -3,7 +3,7 @@
|
||||
// its forms): page header, main/side columns, section cards and a sticky
|
||||
// action bar. Layout only — ant components keep their own design-system look.
|
||||
|
||||
@pf-primary: #1c2d56;
|
||||
@pf-primary: #1d4ed8;
|
||||
@pf-ink: #101828;
|
||||
@pf-muted: #8a94a6;
|
||||
@pf-line: #eef0f5;
|
||||
@@ -106,7 +106,7 @@
|
||||
border-radius: 11px;
|
||||
font-size: 16px;
|
||||
color: @pf-primary;
|
||||
background: rgba(28, 45, 86, 0.07);
|
||||
background: rgba(29, 78, 216, 0.07);
|
||||
}
|
||||
.pf-section__action {
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -27,9 +27,9 @@
|
||||
* Keep in sync with App.vue `antTheme` tokens.
|
||||
*/
|
||||
:root {
|
||||
--selected-bg: rgba(28, 45, 86, 0.08);
|
||||
--selected-bg-hover: rgba(28, 45, 86, 0.12);
|
||||
--hover-bg: rgba(28, 45, 86, 0.04);
|
||||
--selected-bg: rgba(29, 78, 216, 0.08);
|
||||
--selected-bg-hover: rgba(29, 78, 216, 0.12);
|
||||
--hover-bg: rgba(29, 78, 216, 0.04);
|
||||
}
|
||||
|
||||
.ant-menu-light .ant-menu-item-selected,
|
||||
@@ -42,7 +42,7 @@
|
||||
.vertical-nav-menu .ant-menu-item-selected:hover,
|
||||
.vertical-nav-menu .ant-menu-sub .ant-menu-item-selected,
|
||||
.vertical-nav-menu .ant-menu-sub .ant-menu-item-selected:hover {
|
||||
color: var(--primary-color, #1c2d56) !important;
|
||||
color: var(--primary-color, #1d4ed8) !important;
|
||||
background: var(--selected-bg) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -50,7 +50,7 @@
|
||||
.ant-menu-light .ant-menu-item-selected .anticon,
|
||||
.ant-menu-inline .ant-menu-item-selected .anticon,
|
||||
.vertical-nav-menu .ant-menu-item-selected .anticon {
|
||||
color: var(--primary-color, #1c2d56) !important;
|
||||
color: var(--primary-color, #1d4ed8) !important;
|
||||
}
|
||||
|
||||
.ant-menu-light .ant-menu-item-selected::after,
|
||||
@@ -63,7 +63,7 @@
|
||||
/* Select / Cascader / TreeSelect / Transfer / Mentions / Dropdown */
|
||||
.ant-select-item-option-selected:not(.ant-select-item-option-disabled),
|
||||
.ant-select-item-option-active:not(.ant-select-item-option-disabled) {
|
||||
color: var(--primary-color, #1c2d56) !important;
|
||||
color: var(--primary-color, #1d4ed8) !important;
|
||||
}
|
||||
|
||||
.ant-select-item-option-selected:not(.ant-select-item-option-disabled) {
|
||||
@@ -84,7 +84,7 @@
|
||||
.ant-dropdown-menu-submenu-title-selected,
|
||||
.ant-transfer-list-content-item-checked,
|
||||
.ant-mentions-dropdown-menu-item-active {
|
||||
color: var(--primary-color, #1c2d56) !important;
|
||||
color: var(--primary-color, #1d4ed8) !important;
|
||||
background: var(--selected-bg) !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
// LESS build to feed); nothing in the app should reference `--primary`/
|
||||
// `--accent` any more — use the brand tokens below instead.
|
||||
:root {
|
||||
--primary-color: #1c2d56;
|
||||
--primary-color: #1d4ed8;
|
||||
--secondary-color: #836447;
|
||||
--third-color: #f5cb55;
|
||||
--success-color: #28c76f;
|
||||
|
||||
@@ -76,8 +76,8 @@ const iconComponent = computed(() => ICONS[props.icon ?? ''] ?? BarChartOutlined
|
||||
border-radius: 8px;
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
background: rgba(28, 45, 86, 0.06);
|
||||
color: #1c2d56;
|
||||
background: rgba(29, 78, 216, 0.06);
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.stat-copy {
|
||||
@@ -96,7 +96,7 @@ const iconComponent = computed(() => ICONS[props.icon ?? ''] ?? BarChartOutlined
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1c2d56;
|
||||
color: #1d4ed8;
|
||||
line-height: 1.25;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
.gallery-upload__icon {
|
||||
font-size: 16px;
|
||||
color: #1c2d56;
|
||||
color: #1d4ed8;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.gallery-upload__title {
|
||||
@@ -279,7 +279,7 @@ onBeforeUnmount(() => {
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: #1c2d56;
|
||||
color: #1d4ed8;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease;
|
||||
@@ -318,6 +318,6 @@ onBeforeUnmount(() => {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #1c2d56;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -642,7 +642,7 @@ defineExpose({ fetch });
|
||||
}
|
||||
.x-card__name {
|
||||
font-weight: 600;
|
||||
color: #1c2d56;
|
||||
color: #1d4ed8;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<div class="nav-right">
|
||||
<a-dropdown trigger="click" placement="bottomRight" :overlay-class-name="'user-dropdown-overlay'">
|
||||
<span class="user-chip">
|
||||
<a-avatar size="small" style="background-color: #1c2d56">
|
||||
<a-avatar size="small" style="background-color: #1d4ed8">
|
||||
{{ initial }}
|
||||
</a-avatar>
|
||||
<span class="user-name">{{ displayName }}</span>
|
||||
@@ -34,7 +34,7 @@
|
||||
<template #overlay>
|
||||
<div class="user-card">
|
||||
<div class="user-card__head">
|
||||
<a-avatar size="large" style="background-color: #1c2d56">
|
||||
<a-avatar size="large" style="background-color: #1d4ed8">
|
||||
{{ initial }}
|
||||
</a-avatar>
|
||||
<div class="user-card__id">
|
||||
@@ -135,7 +135,7 @@ async function onLogout() {
|
||||
}
|
||||
.page-title {
|
||||
font-size: 16px;
|
||||
color: #1c2d56;
|
||||
color: #1d4ed8;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -191,7 +191,7 @@ async function onLogout() {
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 28px -8px rgba(28, 45, 86, 0.28);
|
||||
box-shadow: 0 8px 28px -8px rgba(29, 78, 216, 0.28);
|
||||
}
|
||||
|
||||
.user-dropdown-overlay .user-card__head {
|
||||
@@ -210,7 +210,7 @@ async function onLogout() {
|
||||
.user-dropdown-overlay .user-card__name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1c2d56;
|
||||
color: #1d4ed8;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -246,7 +246,7 @@ async function onLogout() {
|
||||
|
||||
.user-dropdown-overlay .user-card__menu .ant-menu-item:hover {
|
||||
background: #f4f5f6;
|
||||
color: #1c2d56;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.user-dropdown-overlay .user-card__menu .anticon {
|
||||
|
||||
@@ -215,7 +215,7 @@ function onClick({ key }: { key: string | number }) {
|
||||
border-radius: 11px;
|
||||
background: #fff;
|
||||
border: 1px solid #eef0f5;
|
||||
box-shadow: 0 4px 12px -6px rgba(28, 45, 86, 0.35);
|
||||
box-shadow: 0 4px 12px -6px rgba(29, 78, 216, 0.35);
|
||||
}
|
||||
.nav-logo img {
|
||||
width: 64%;
|
||||
@@ -229,7 +229,7 @@ function onClick({ key }: { key: string | number }) {
|
||||
.nav-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #1c2d56;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
.nav-subtitle {
|
||||
font-size: 11px;
|
||||
@@ -308,22 +308,22 @@ function onClick({ key }: { key: string | number }) {
|
||||
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-item:not(.ant-menu-item-selected):hover,
|
||||
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-submenu-title:hover {
|
||||
background: #f5f7fb !important;
|
||||
color: #1c2d56 !important;
|
||||
color: #1d4ed8 !important;
|
||||
}
|
||||
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-item:hover .ant-menu-item-icon,
|
||||
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-submenu-title:hover .ant-menu-item-icon {
|
||||
color: #1c2d56;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
/* active item */
|
||||
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-item-selected {
|
||||
position: relative;
|
||||
background: rgba(28, 45, 86, 0.08) !important;
|
||||
color: #1c2d56 !important;
|
||||
background: rgba(29, 78, 216, 0.08) !important;
|
||||
color: #1d4ed8 !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-item-selected .ant-menu-item-icon {
|
||||
color: #1c2d56;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-inline .ant-menu-item-selected::after {
|
||||
content: '';
|
||||
@@ -335,13 +335,13 @@ function onClick({ key }: { key: string | number }) {
|
||||
width: 3px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: #1c2d56;
|
||||
background: #1d4ed8;
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-submenu-selected > .ant-menu-submenu-title,
|
||||
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-submenu-selected > .ant-menu-submenu-title .ant-menu-item-icon {
|
||||
color: #1c2d56;
|
||||
color: #1d4ed8;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -391,16 +391,16 @@ function onClick({ key }: { key: string | number }) {
|
||||
.nav-popup .ant-menu-item:not(.ant-menu-item-selected):hover,
|
||||
.nav-popup .ant-menu-submenu-title:hover {
|
||||
background: #f5f7fb !important;
|
||||
color: #1c2d56 !important;
|
||||
color: #1d4ed8 !important;
|
||||
}
|
||||
.nav-popup .ant-menu-item-selected {
|
||||
position: relative;
|
||||
background: rgba(28, 45, 86, 0.08) !important;
|
||||
color: #1c2d56 !important;
|
||||
background: rgba(29, 78, 216, 0.08) !important;
|
||||
color: #1d4ed8 !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
.nav-popup .ant-menu-item-selected .ant-menu-item-icon {
|
||||
color: #1c2d56;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
.nav-popup .ant-menu-item-selected::after {
|
||||
content: '';
|
||||
@@ -412,19 +412,19 @@ function onClick({ key }: { key: string | number }) {
|
||||
width: 3px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: #1c2d56;
|
||||
background: #1d4ed8;
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
.nav-popup .ant-menu-submenu-selected > .ant-menu-submenu-title {
|
||||
color: #1c2d56;
|
||||
color: #1d4ed8;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* collapsed rail: the icon of the group holding the active page */
|
||||
#app .vertical-nav-menu .ant-menu-inline-collapsed .ant-menu-submenu-selected > .ant-menu-submenu-title,
|
||||
#app .vertical-nav-menu .ant-menu-inline-collapsed > .ant-menu-item-selected {
|
||||
background: rgba(28, 45, 86, 0.08) !important;
|
||||
color: #1c2d56 !important;
|
||||
background: rgba(29, 78, 216, 0.08) !important;
|
||||
color: #1d4ed8 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
// MAIN COLORS
|
||||
export const colors = {
|
||||
primary: '#1c2d56',
|
||||
primary: '#1d4ed8',
|
||||
secondary: '#836447',
|
||||
third: '#f5cb55',
|
||||
success: '#28C76F',
|
||||
|
||||
@@ -349,7 +349,7 @@ const data = reactive<Record<string, any>>({
|
||||
|
||||
// ── colours matching the rest of the admin panel (StatisticsCardLine's own
|
||||
// icon-color classes and ant's card chrome) ────────────────────────────────
|
||||
const COLOR_PRIMARY = '#1c2d56';
|
||||
const COLOR_PRIMARY = '#1d4ed8';
|
||||
const COLOR_SUCCESS = '#28c76f';
|
||||
const COLOR_WARNING = '#ff9f43';
|
||||
const COLOR_DANGER = '#ea5455';
|
||||
@@ -604,7 +604,7 @@ b {
|
||||
font-weight: 500;
|
||||
}
|
||||
.dashboard {
|
||||
--c-primary: #1c2d56;
|
||||
--c-primary: #1d4ed8;
|
||||
--c-success: #28c76f;
|
||||
--c-warning: #ff9f43;
|
||||
--c-danger: #ea5455;
|
||||
@@ -686,7 +686,7 @@ b {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(28, 45, 86, 0.06);
|
||||
background: rgba(29, 78, 216, 0.06);
|
||||
color: var(--c-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
@@ -717,7 +717,7 @@ b {
|
||||
}
|
||||
.kpi {
|
||||
--tone: var(--c-primary);
|
||||
--tone-bg: rgba(28, 45, 86, 0.08);
|
||||
--tone-bg: rgba(29, 78, 216, 0.08);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 18px 18px 14px;
|
||||
@@ -881,7 +881,7 @@ b {
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tone-primary { color: var(--c-primary); background: rgba(28, 45, 86, 0.08); }
|
||||
.tone-primary { color: var(--c-primary); background: rgba(29, 78, 216, 0.08); }
|
||||
.tone-success { color: var(--c-success); background: rgba(40, 199, 111, 0.12); }
|
||||
.tone-warning { color: var(--c-warning); background: rgba(255, 159, 67, 0.14); }
|
||||
.tone-danger { color: var(--c-danger); background: rgba(234, 84, 85, 0.12); }
|
||||
|
||||
@@ -137,9 +137,9 @@ b {
|
||||
border-color: #c9d0dd;
|
||||
}
|
||||
.kind-card.is-on {
|
||||
border-color: #1c2d56;
|
||||
background: rgba(28, 45, 86, 0.03);
|
||||
box-shadow: 0 0 0 3px rgba(28, 45, 86, 0.08);
|
||||
border-color: #1d4ed8;
|
||||
background: rgba(29, 78, 216, 0.03);
|
||||
box-shadow: 0 0 0 3px rgba(29, 78, 216, 0.08);
|
||||
}
|
||||
.kind-card__icon {
|
||||
width: 42px;
|
||||
@@ -155,7 +155,7 @@ b {
|
||||
}
|
||||
.kind-card.is-on .kind-card__icon {
|
||||
color: #fff;
|
||||
background: #1c2d56;
|
||||
background: #1d4ed8;
|
||||
}
|
||||
.kind-card__text {
|
||||
display: flex;
|
||||
@@ -174,7 +174,7 @@ b {
|
||||
.kind-card__check {
|
||||
margin-inline-start: auto;
|
||||
font-size: 16px;
|
||||
color: #1c2d56;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
|
||||
@@ -651,7 +651,7 @@ onMounted(async () => {
|
||||
|
||||
<style scoped>
|
||||
.product-form-page {
|
||||
--c-primary: #1c2d56;
|
||||
--c-primary: #1d4ed8;
|
||||
--ink: #101828;
|
||||
--muted: #8a94a6;
|
||||
--line: #eef0f5;
|
||||
@@ -731,7 +731,7 @@ onMounted(async () => {
|
||||
border-color: #b4bccb;
|
||||
}
|
||||
.variant-gallery__item.is-on {
|
||||
border-color: #1c2d56;
|
||||
border-color: #1d4ed8;
|
||||
}
|
||||
.variant-gallery__item img {
|
||||
width: 100%;
|
||||
@@ -779,7 +779,7 @@ onMounted(async () => {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1c2d56;
|
||||
color: #1d4ed8;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.product-name-badge {
|
||||
@@ -814,8 +814,8 @@ onMounted(async () => {
|
||||
font-size: 13px;
|
||||
}
|
||||
.header-save-btn {
|
||||
background: #1c2d56;
|
||||
border-color: #1c2d56;
|
||||
background: #1d4ed8;
|
||||
border-color: #1d4ed8;
|
||||
height: 38px;
|
||||
padding: 0 18px;
|
||||
border-radius: 8px;
|
||||
@@ -849,7 +849,7 @@ onMounted(async () => {
|
||||
border: 1px solid #edf0f5;
|
||||
border-radius: 12px;
|
||||
padding: 20px 22px 24px;
|
||||
box-shadow: 0 1px 2px rgba(28, 45, 86, 0.02);
|
||||
box-shadow: 0 1px 2px rgba(29, 78, 216, 0.02);
|
||||
}
|
||||
|
||||
.form-section-head {
|
||||
@@ -877,8 +877,8 @@ onMounted(async () => {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
background: rgba(28, 45, 86, 0.05);
|
||||
color: #1c2d56;
|
||||
background: rgba(29, 78, 216, 0.05);
|
||||
color: #1d4ed8;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -892,7 +892,7 @@ onMounted(async () => {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1c2d56;
|
||||
color: #1d4ed8;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.section-subtitle {
|
||||
@@ -965,7 +965,7 @@ onMounted(async () => {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.featured-upload-card:hover {
|
||||
border-color: #1c2d56;
|
||||
border-color: #1d4ed8;
|
||||
background: #ffffff;
|
||||
}
|
||||
.featured-label {
|
||||
@@ -998,8 +998,8 @@ onMounted(async () => {
|
||||
|
||||
/* ── جدول تنوعها ── */
|
||||
.add-variant-btn {
|
||||
background: #1c2d56;
|
||||
border-color: #1c2d56;
|
||||
background: #1d4ed8;
|
||||
border-color: #1d4ed8;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -1044,7 +1044,7 @@ onMounted(async () => {
|
||||
font-size: 13px;
|
||||
}
|
||||
.weight-cell {
|
||||
color: #1c2d56;
|
||||
color: #1d4ed8;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -1076,10 +1076,10 @@ onMounted(async () => {
|
||||
border-radius: 4px;
|
||||
}
|
||||
.edit-btn {
|
||||
color: #1c2d56;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
.edit-btn:hover {
|
||||
background: rgba(28, 45, 86, 0.08);
|
||||
background: rgba(29, 78, 216, 0.08);
|
||||
}
|
||||
.delete-btn {
|
||||
color: #ea5455;
|
||||
@@ -1105,8 +1105,8 @@ onMounted(async () => {
|
||||
}
|
||||
.empty-add-btn {
|
||||
border-radius: 6px;
|
||||
color: #1c2d56;
|
||||
border-color: #1c2d56;
|
||||
color: #1d4ed8;
|
||||
border-color: #1d4ed8;
|
||||
}
|
||||
|
||||
/* ── نوار اکشن پایین ── */
|
||||
@@ -1119,8 +1119,8 @@ onMounted(async () => {
|
||||
border-top: 1px solid #edf0f5;
|
||||
}
|
||||
.submit-btn {
|
||||
background: #1c2d56;
|
||||
border-color: #1c2d56;
|
||||
background: #1d4ed8;
|
||||
border-color: #1d4ed8;
|
||||
height: 42px;
|
||||
padding: 0 24px;
|
||||
font-size: 14px;
|
||||
@@ -1183,7 +1183,7 @@ onMounted(async () => {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.variant-picker__item:hover {
|
||||
border-color: #1c2d56;
|
||||
border-color: #1d4ed8;
|
||||
}
|
||||
.variant-picker__item img {
|
||||
width: 100%;
|
||||
|
||||
@@ -229,7 +229,7 @@ async function loginJWT() {
|
||||
<style scoped>
|
||||
#page-login {
|
||||
--bg: #060b1c;
|
||||
--navy: #1c2d56;
|
||||
--navy: #1d4ed8;
|
||||
--gold-1: #fff1c1;
|
||||
--gold-2: #f2c95c;
|
||||
--gold-3: #c8902a;
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
<ul>
|
||||
<li class="comment-item">
|
||||
<span class="img">
|
||||
<UserOutlined style="font-size: 48px; color: #1c2d56" />
|
||||
<UserOutlined style="font-size: 48px; color: #1d4ed8" />
|
||||
</span>
|
||||
<div class="comment-body">
|
||||
<h5>
|
||||
|
||||
@@ -36,9 +36,9 @@ onMounted(async () => {
|
||||
padding: 1rem 1.5rem;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 6px;
|
||||
color: #1c2d56;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
.link-card:hover { border-color: #1c2d56; }
|
||||
.link-card:hover { border-color: #1d4ed8; }
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.links {
|
||||
|
||||
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 87 KiB |
@@ -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') {
|
||||
|
||||
|
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 |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 87 KiB |
@@ -1,21 +1,70 @@
|
||||
/* 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+*/
|
||||
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+*/
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -24,8 +73,8 @@
|
||||
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+*/
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -34,8 +83,8 @@
|
||||
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+*/
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -44,8 +93,8 @@
|
||||
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+*/
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -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+*/
|
||||
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+*/
|
||||
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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,202 +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 },
|
||||
size ? `card--${size}` : null,
|
||||
]"
|
||||
data-product-origin
|
||||
@click="emit('open', value)"
|
||||
>
|
||||
<div class="card__art">
|
||||
<img v-lazy="image" :alt="title" />
|
||||
<button type="button" class="card__fav" :class="{ 'is-on': favorites.isFavorite(id) }" @click.stop="favorites.toggle(id)">
|
||||
<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
|
||||
/** Masonry height variant for the image well (asymmetric set). */
|
||||
size?: 'hero' | 'tall' | 'mid' | 'short' | 'squat' | 'square'
|
||||
}>()
|
||||
const emit = defineEmits<{ (e: 'open', value: ProductSummary): void }>()
|
||||
|
||||
const favorites = useFavoritesStore()
|
||||
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;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card--wide .card__art {
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
.card--hero .card__art {
|
||||
aspect-ratio: 2 / 3;
|
||||
}
|
||||
|
||||
.card--tall .card__art {
|
||||
aspect-ratio: 3 / 4;
|
||||
}
|
||||
|
||||
.card--mid .card__art {
|
||||
aspect-ratio: 4 / 5;
|
||||
}
|
||||
|
||||
.card--square .card__art,
|
||||
.card--short .card__art {
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.card--squat .card__art {
|
||||
aspect-ratio: 6 / 5;
|
||||
}
|
||||
|
||||
.card__art img {
|
||||
width: 100%;
|
||||
max-width: 220px;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
display: block;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// Dark mode: product photos are shot on white, so they sit on a light mat
|
||||
// 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
|
||||
/**
|
||||
* 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 {
|
||||
transform: `translate3d(${base + dragPercent}%, 0, 0)`,
|
||||
transition: dragging.value ? 'none' : undefined,
|
||||
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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,16 +26,13 @@ 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)
|
||||
/** 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)
|
||||
|
||||
/** What changed between the running build and the server's, newest first. */
|
||||
const changes = computed(() => {
|
||||
const info = available.value
|
||||
if (!info) return []
|
||||
return (info.history ?? []).filter((h) => h.build > runningBuild && h.build <= info.build)
|
||||
})
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
let listeners = 0
|
||||
|
||||
async function check() {
|
||||
if (import.meta.env.DEV || !runningBuild || available.value) return
|
||||
@@ -46,6 +46,18 @@ export function useAppUpdate() {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
if (!info) return []
|
||||
return (info.history ?? []).filter((h) => h.build > runningBuild && h.build <= info.build)
|
||||
})
|
||||
|
||||
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(() => {
|
||||
listeners += 1
|
||||
if (listeners === 1) {
|
||||
check()
|
||||
timer = setInterval(check, CHECK_EVERY_MS)
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
listeners -= 1
|
||||
if (listeners <= 0) {
|
||||
listeners = 0
|
||||
if (timer) clearInterval(timer)
|
||||
timer = undefined
|
||||
document.removeEventListener('visibilitychange', onVisible)
|
||||
}
|
||||
})
|
||||
|
||||
return { available, changes, updating, applyUpdate, check }
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -94,21 +94,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>
|
||||
@@ -414,14 +422,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 {
|
||||
@@ -435,5 +460,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>
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
2. the live 18k rate strip (real: GET /pricing/latest)
|
||||
3. an editorial showcase hero built from real categories
|
||||
4. the category rail (real: derived from GET /catalog/products)
|
||||
5. "برگزیده" — masonry of product cards
|
||||
6. "تازهها" — a horizontal rail
|
||||
5. "برگزیده" — staggered 2-col masonry (wireframe: unequal card heights, flush bottoms)
|
||||
6. "تازهها" — full-width horizontal ProductRow list
|
||||
7. the account block (real: GET /customers/:id when signed in)
|
||||
|
||||
Data honesty, carried over from the previous version deliberately:
|
||||
@@ -68,22 +68,19 @@
|
||||
/>
|
||||
<div class="home__masonry">
|
||||
<div class="home__masonryCol">
|
||||
<ShowcaseCard
|
||||
v-for="(cell, i) in masonryCols.start"
|
||||
<ProductCard
|
||||
v-for="cell in masonryCols.left"
|
||||
:key="cell.item.id"
|
||||
:value="cell.item"
|
||||
:badge="i === 0 ? 'برگزیده' : undefined"
|
||||
:size="cell.size"
|
||||
@open="openProduct"
|
||||
/>
|
||||
</div>
|
||||
<div class="home__masonryCol">
|
||||
<ShowcaseCard
|
||||
v-for="cell in masonryCols.end"
|
||||
<ProductCard
|
||||
v-for="cell in masonryCols.right"
|
||||
:key="cell.item.id"
|
||||
:value="cell.item"
|
||||
:size="cell.size"
|
||||
@open="openProduct"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,14 +93,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>
|
||||
|
||||
@@ -112,20 +103,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>
|
||||
</PullRefresh>
|
||||
</div>
|
||||
@@ -137,12 +114,12 @@ import { computed, onActivated, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Loading, PullRefresh } from 'vant'
|
||||
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 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'
|
||||
@@ -177,35 +154,53 @@ const adminSlides = ref<AdminSlide[] | null>(reads.peek<AdminSlide[]>(KEYS.homeS
|
||||
const { revealed, revealDelay, reveal } = useReveal('home')
|
||||
|
||||
/**
|
||||
* Featured masonry — two real columns with deliberately *different* height
|
||||
* rhythms (not a mirrored tall/short alternation), so the block reads
|
||||
* asymmetric. RTL puts `start` on the right.
|
||||
* 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.
|
||||
*/
|
||||
const featuredProducts = computed(() => products.value.slice(0, 9))
|
||||
const newestProducts = computed(() => products.value.slice(9, 17))
|
||||
/**
|
||||
* 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'
|
||||
|
||||
/** Start column (visual right in RTL): opens tall, then irregular. */
|
||||
const START_SIZES: MasonrySize[] = ['hero', 'short', 'tall', 'mid', 'squat']
|
||||
/** End column: different sequence + often one fewer/taller cards. */
|
||||
const END_SIZES: MasonrySize[] = ['mid', 'hero', 'short', 'tall']
|
||||
const 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 start: { item: ProductSummary; size: MasonrySize }[] = []
|
||||
const end: { item: ProductSummary; size: MasonrySize }[] = []
|
||||
const left: { item: ProductSummary; size: MasonrySize }[] = []
|
||||
const right: { item: ProductSummary; size: MasonrySize }[] = []
|
||||
|
||||
items.forEach((item, i) => {
|
||||
// Uneven split: 5 on start / 4 on end when we have 9 — keeps columns unequal.
|
||||
if (i % 2 === 0 && start.length < START_SIZES.length) {
|
||||
start.push({ item, size: START_SIZES[start.length] })
|
||||
} else if (end.length < END_SIZES.length) {
|
||||
end.push({ item, size: END_SIZES[end.length] })
|
||||
} else {
|
||||
start.push({ item, size: START_SIZES[start.length % START_SIZES.length] })
|
||||
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 { start, end }
|
||||
return { left, right }
|
||||
})
|
||||
|
||||
// The hero shows ONLY what the admin uploaded in the slider section
|
||||
@@ -218,12 +213,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' })
|
||||
@@ -319,7 +308,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;
|
||||
@@ -327,7 +316,8 @@ onActivated(async () => {
|
||||
}
|
||||
|
||||
.home__logo {
|
||||
height: 21px;
|
||||
height: 52px;
|
||||
width: 52px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@@ -361,7 +351,7 @@ onActivated(async () => {
|
||||
}
|
||||
|
||||
.home__body {
|
||||
padding-top: 62px;
|
||||
padding-top: 72px;
|
||||
padding-bottom: 90px;
|
||||
}
|
||||
|
||||
@@ -381,9 +371,11 @@ onActivated(async () => {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
/* Asymmetric masonry: two independent columns, unequal size rhythms. */
|
||||
/* 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;
|
||||
}
|
||||
@@ -394,20 +386,13 @@ onActivated(async () => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 11px;
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.home__reel {
|
||||
.home__rows {
|
||||
display: flex;
|
||||
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;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.home__empty {
|
||||
@@ -426,23 +411,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>
|
||||
|
||||
@@ -96,12 +96,6 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SectionHead
|
||||
v-if="filteredProducts.length"
|
||||
title="نتایج"
|
||||
:subtitle="`${filteredProducts.length} محصول`"
|
||||
/>
|
||||
|
||||
<div v-if="filteredProducts.length" class="search__grid">
|
||||
<ProductCard v-for="item in visibleProducts" :key="item.id" :value="item" />
|
||||
</div>
|
||||
@@ -145,7 +139,6 @@ 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 { useFiltersStore } from '../../stores/filtersStore'
|
||||
import { useCatalogStore } from '../../stores/catalogStore'
|
||||
@@ -294,20 +287,24 @@ onMounted(() => loadProducts())
|
||||
.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 {
|
||||
|
||||
@@ -13,11 +13,15 @@
|
||||
|
||||
### فایلهای لوگو
|
||||
|
||||
منبع اصلی (آیکون بدون متن): [`scripts/brand-assets/hedena-icon.webp`](../scripts/brand-assets/hedena-icon.webp)
|
||||
|
||||
- `apps/user-app/public/img/brand-logo.png` و `.webp`
|
||||
- `apps/user-app/src/assets/img/brand-logo.png`
|
||||
- `apps/user-app/public/img/no-items.png` و `src/assets/img/no-items.png` (placeholder تصویر محصول)
|
||||
- `apps/admin-panel/src/assets/images/logo/brand-icon.png` و `.webp`، `brand-horizontal.png`
|
||||
- آیکونهای PWA و favicon: `apps/*/public/icon-192.png`، `icon-512.png`، `favicon.*`
|
||||
- اسکریپت: [`scripts/generate-brand-icons.py`](../scripts/generate-brand-icons.py)
|
||||
- اسکریپت (ترجیحی): [`scripts/generate-brand-icons.mjs`](../scripts/generate-brand-icons.mjs) — نیاز به `sharp` و `to-ico`
|
||||
- اسکریپت جایگزین: [`scripts/generate-brand-icons.py`](../scripts/generate-brand-icons.py)
|
||||
|
||||
### بعد از تغییر
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Showcase slider — luxury crossfade
|
||||
|
||||
**Status:** approved
|
||||
**Date:** 2026-10-15
|
||||
**Scope:** `apps/user-app` home hero (`ShowcaseSlider.vue`)
|
||||
|
||||
## Intent
|
||||
|
||||
Calm, luxury gold-vitrine motion. Special feeling lives in the **slide change**, not idle fidgeting.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Choice | Decision |
|
||||
|--------|----------|
|
||||
| Vibe | Luxury / soft (not cinematic parallax, not brand wipe) |
|
||||
| Transition | Soft crossfade + subtle settle zoom |
|
||||
| Swipe | Kept; commit settles with the same crossfade (no hard horizontal slide) |
|
||||
| Autoplay | 6s; off under reduced motion |
|
||||
| Dots | Unchanged pill indicator |
|
||||
| Out of scope | Multi-layer parallax, brand-color curtain, home layout changes |
|
||||
|
||||
## Motion spec
|
||||
|
||||
- Stack slides absolutely inside the rounded frame.
|
||||
- Active: `opacity: 1`, art `scale(1)`.
|
||||
- Incoming settle: art eases from `scale(1.03)` → `1`.
|
||||
- Duration ≈ `1.15s`, easing `cubic-bezier(0.22, 0.61, 0.36, 1)` (softer, less snap).
|
||||
- While dragging, preview fade toward next/prev by drag ratio; on release past ~18% width, change index.
|
||||
- `prefers-reduced-motion` / appearance reduced-motion: instant opacity swap, no scale animation, no autoplay.
|
||||
|
||||
## Non-goals
|
||||
|
||||
Invented product prices, extra copy overlays on slides, new dependencies (Swiper, etc.).
|
||||
@@ -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.
|
||||
@@ -0,0 +1,176 @@
|
||||
# PWA install prompt + platform guides
|
||||
|
||||
Date: 2026-10-15
|
||||
App: `apps/user-app`
|
||||
|
||||
## Goal
|
||||
|
||||
Show a brand-focused centered install modal automatically after first useful visit, with separate Android-web and iOS install guides. Do not nag: if the user dismisses, wait 7 days before offering again (only while not installed). Once installed, never show again.
|
||||
|
||||
## Decisions (locked)
|
||||
|
||||
| Topic | Choice |
|
||||
|---|---|
|
||||
| Entry | Auto prompt after short delay (not a permanent settings entry in v1) |
|
||||
| UI | Centered modal, brand-first, one primary CTA |
|
||||
| Re-offer | 7 days after dismiss, only if not installed |
|
||||
| Installed | Never show again |
|
||||
| iOS / no native prompt | Same modal shell → visual step guide for that platform |
|
||||
| Guides | Separate copy/steps for Android web vs iOS |
|
||||
| Style | Follow existing `hg-dialog` / `DialogVant` island pattern |
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Admin panel PWA install UX
|
||||
- Push-notification permission prompts
|
||||
- Changing `vite-plugin-pwa` manifest / SW strategy (already present)
|
||||
- Persistent “Install app” row in profile/menu (can add later)
|
||||
- Desktop-specific illustrated guide (desktop Chrome may still get native prompt or Android-style fallback text)
|
||||
|
||||
## Architecture
|
||||
|
||||
Three pieces in `user-app`:
|
||||
|
||||
1. **`usePwaInstall`** (composable)
|
||||
- Listen for `beforeinstallprompt`, stash the event, expose `promptInstall()`
|
||||
- Detect installed / standalone (`display-mode: standalone`, `navigator.standalone` on iOS)
|
||||
- Detect platform for guide content: `ios` | `android` (default non-iOS mobile/desktop to android-style guide)
|
||||
- Read/write persistence; expose `shouldShowPrompt`, `open()`, `dismiss()`, `markInstalled()`
|
||||
- After ~4s on eligible routes, set `visible` if `shouldShowPrompt`
|
||||
|
||||
2. **`InstallPromptModal.vue`**
|
||||
- Centered `van-dialog` with `hg-dialog` styling (or thin wrapper consistent with `DialogVant`)
|
||||
- Modes: `offer` | `guide`
|
||||
- Offer: brand logo, title, one line, primary CTA, subtle close
|
||||
- Guide: platform-specific steps; secondary “متوجه شدم” dismisses
|
||||
|
||||
3. **Mount in `App.vue`**
|
||||
- Sibling of `AppUpdateGate`
|
||||
- Skip while route uses `FullPage` layout (login/register and similar)
|
||||
|
||||
Existing SW registration via `useRegisterSW` stays as-is; this feature only adds install UX.
|
||||
|
||||
## Show / hide rules
|
||||
|
||||
Show only when **all** are true:
|
||||
|
||||
1. Not already installed (standalone / `navigator.standalone` / persisted `installed`)
|
||||
2. `dismissedAt` is missing, or older than **7 days**
|
||||
3. Current route is not `meta.layout === 'FullPage'`
|
||||
4. Initial delay of **~4 seconds** after app shell ready has elapsed
|
||||
5. Document is visible (`document.visibilityState === 'visible'`) — optional soft guard
|
||||
|
||||
On dismiss (×, overlay, or “متوجه شدم”):
|
||||
|
||||
- Set `pwaInstall.dismissedAt = Date.now()`
|
||||
- Close modal; reset mode to `offer` for next open
|
||||
|
||||
On successful native install (`appinstalled` event or user accepted `prompt()`):
|
||||
|
||||
- Set `pwaInstall.installed = true`
|
||||
- Clear need to re-offer; never show again
|
||||
|
||||
On later visit already in standalone:
|
||||
|
||||
- Treat as installed (set `installed` if not set); never show
|
||||
|
||||
## Persistence
|
||||
|
||||
`localStorage` keys (single JSON object preferred, matching cart/appearance patterns):
|
||||
|
||||
```json
|
||||
{
|
||||
"dismissedAt": null,
|
||||
"installed": false
|
||||
}
|
||||
```
|
||||
|
||||
Key name: `pwaInstall` (or `hg.pwaInstall` if namespacing is preferred in implementation).
|
||||
|
||||
Constants:
|
||||
|
||||
- `REOFFER_MS = 7 * 24 * 60 * 60 * 1000`
|
||||
- `INITIAL_DELAY_MS = 4000`
|
||||
|
||||
## Modal content
|
||||
|
||||
### Offer mode (both platforms)
|
||||
|
||||
| Element | Copy / asset |
|
||||
|---|---|
|
||||
| Media | `brand-logo` (existing public/assets path) |
|
||||
| Title | نصب اپلیکیشن هدنا گلد |
|
||||
| Body | دسترسی سریعتر و تجربهٔ شبیه اپ واقعی |
|
||||
| Primary CTA | If deferred prompt ready: نصب اپ — else: مشاهده راهنما |
|
||||
| Close | Small ×; no loud secondary cancel |
|
||||
|
||||
CTA behavior:
|
||||
|
||||
- Deferred prompt available → `event.prompt()`; on accept → `markInstalled()`; on dismiss of browser UI only → keep our `dismissedAt` unset unless user also closed our modal (browser decline alone does not start the 7-day clock; closing our modal does)
|
||||
- No deferred prompt → switch modal to `guide` for detected platform (do not dismiss yet)
|
||||
|
||||
### Guide — Android web
|
||||
|
||||
Title: راهنمای نصب
|
||||
|
||||
1. منوی مرورگر (⋮) را باز کنید
|
||||
2. گزینهٔ «نصب برنامه» یا «Add to Home screen» را بزنید
|
||||
3. نصب را تأیید کنید
|
||||
|
||||
Footer CTA: متوجه شدم → dismiss + 7-day clock
|
||||
|
||||
### Guide — iOS
|
||||
|
||||
Title: راهنمای نصب
|
||||
|
||||
1. دکمهٔ Share (مربع با فلش رو به بالا) را بزنید
|
||||
2. «Add to Home Screen» / افزودن به صفحهٔ اصلی را انتخاب کنید
|
||||
3. Add را تأیید کنید
|
||||
|
||||
Footer CTA: متوجه شدم → dismiss + 7-day clock
|
||||
|
||||
Guides use short icon+text rows (no long paragraphs). Illustrations may be simple inline SVG icons (share / menu / home); no new marketing photography required for v1.
|
||||
|
||||
## Platform detection (guide only)
|
||||
|
||||
- **iOS:** iPhone/iPad/iPod UA, or iPadOS desktop UA with touch — use iOS guide
|
||||
- **Else:** Android-web guide (covers Android Chrome without prompt, and as fallback elsewhere)
|
||||
|
||||
Native `beforeinstallprompt` path is independent of guide platform: if the event exists, offer CTA installs directly regardless of guide branch.
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
| Case | Behavior |
|
||||
|---|---|
|
||||
| `prompt()` rejects / user cancels browser sheet | Stay closed or return to offer only if still open; do **not** set `installed`; if our modal was closed by user earlier, 7-day rule already applies |
|
||||
| Already installed mid-session (`appinstalled`) | Close modal; set `installed` |
|
||||
| Private mode / `localStorage` throws | Fail soft: skip auto-show (no crash); optional in-memory session-only suppress |
|
||||
| User opens app from home screen | Never show |
|
||||
| Update gate (`AppUpdateGate`) visible | Do not stack: keep install modal closed while the update gate is open; schedule or re-check after it closes |
|
||||
| HTTPS / installability not met | No deferred prompt → auto path still can show offer → guide |
|
||||
|
||||
## Testing (manual)
|
||||
|
||||
1. Fresh profile, Android Chrome (installable): after ~4s modal → نصب اپ → browser prompt → accept → never again
|
||||
2. Same, dismiss modal → gone; advance clock / spoof `dismissedAt` −8 days → shows again
|
||||
3. iOS Safari: after ~4s → مشاهده راهنما → iOS steps → متوجه شدم → 7-day silence
|
||||
4. Standalone launch: no modal
|
||||
5. Login (`FullPage`): no modal until leaving that layout and delay elapses on an eligible view
|
||||
|
||||
## File sketch
|
||||
|
||||
```
|
||||
apps/user-app/src/composables/usePwaInstall.ts
|
||||
apps/user-app/src/components/pwa/InstallPromptModal.vue
|
||||
apps/user-app/src/App.vue (mount modal)
|
||||
```
|
||||
|
||||
Optional tiny helpers for UA / display-mode can live next to the composable.
|
||||
|
||||
## Success criteria
|
||||
|
||||
- Brand-first centered modal, one primary CTA
|
||||
- Android and iOS guides are distinct
|
||||
- Auto once per window of eligibility; 7-day re-offer only if not installed
|
||||
- Installed / standalone users never see it
|
||||
- No change to existing SW/update-gate behavior beyond coexistence
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -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')
|
||||
@@ -1,8 +1,12 @@
|
||||
"""
|
||||
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
|
||||
|
||||
# Point `src` at your Hedena Gold master logo, then run from the repo root.
|
||||
src = Path(r"path/to/hedena-gold-logo.webp")
|
||||
src = Path(__file__).resolve().parent / "brand-assets" / "hedena-icon.webp"
|
||||
img = Image.open(src).convert("RGBA")
|
||||
print("source size", img.size)
|
||||
|
||||
@@ -10,10 +14,13 @@ 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")
|
||||
@@ -23,8 +30,9 @@ for size, path in [
|
||||
(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"),
|
||||
]:
|
||||
icon = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
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
|
||||
@@ -32,3 +40,5 @@ for size, path in [
|
||||
icon.paste(fitted, (x, y), fitted)
|
||||
icon.save(path, "PNG")
|
||||
print("wrote", path)
|
||||
|
||||
print("Note: favicon.ico is generated by scripts/generate-brand-icons.mjs (to-ico).")
|
||||
|
||||