feat: new Tehrani brand logo, custom button audience, trim dashboard

- Replace the crown logo with logo.svg / logo_bk.svg across both frontends;
  regenerate favicons and PWA icons via scripts/generate-brand-icons.cjs
- Custom button: admin chooses whether it shows before login or only after
- Dashboard: drop the survey-majority banner, hide the custom-order row

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mahmood
2026-11-14 16:53:25 +03:30
co-authored by Cursor
parent 0f6ad79ae7
commit de13445109
37 changed files with 428 additions and 105 deletions
+2 -2
View File
@@ -3,8 +3,8 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/favicon.png?v=2" /> <link rel="icon" type="image/png" href="/favicon.png?v=3" />
<link rel="shortcut icon" href="/favicon.ico?v=2" /> <link rel="shortcut icon" href="/favicon.ico?v=3" />
<title>%BRAND_TITLE%</title> <title>%BRAND_TITLE%</title>
</head> </head>
<body> <body>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

@@ -19,7 +19,7 @@
> >
<div class="nav-header" :class="{ 'is-collapsed': collapsed }"> <div class="nav-header" :class="{ 'is-collapsed': collapsed }">
<span class="nav-logo"> <span class="nav-logo">
<img src="../../assets/images/logo/brand-icon.png" alt="" /> <img src="../../assets/images/logo/brand-logo.svg" alt="" />
</span> </span>
<span v-if="!collapsed" class="nav-brand"> <span v-if="!collapsed" class="nav-brand">
<span class="nav-title">{{ title }}</span> <span class="nav-title">{{ title }}</span>
@@ -207,8 +207,9 @@ function onClick({ key }: { key: string | number }) {
padding: 0; padding: 0;
} }
.nav-logo { .nav-logo {
width: 38px; /* wide: the mark is about 1.45:1 */
height: 38px; width: 54px;
height: 40px;
flex-shrink: 0; flex-shrink: 0;
display: grid; display: grid;
place-items: center; place-items: center;
@@ -218,7 +219,7 @@ function onClick({ key }: { key: string | number }) {
box-shadow: 0 4px 12px -6px rgba(28, 45, 86, 0.35); box-shadow: 0 4px 12px -6px rgba(28, 45, 86, 0.35);
} }
.nav-logo img { .nav-logo img {
width: 64%; width: 86%;
} }
.nav-brand { .nav-brand {
display: flex; display: flex;
+6 -5
View File
@@ -20,7 +20,7 @@
<section class="hero" aria-hidden="true"> <section class="hero" aria-hidden="true">
<div class="brand-row"> <div class="brand-row">
<div class="logo-chip"> <div class="logo-chip">
<img src="../../assets/images/logo/brand-icon.png" alt="" /> <img src="../../assets/images/logo/brand-logo.svg" alt="" />
</div> </div>
<span class="brand-name">{{ brand.nameEn }}</span> <span class="brand-name">{{ brand.nameEn }}</span>
</div> </div>
@@ -83,7 +83,7 @@
<div class="glass-card"> <div class="glass-card">
<div class="card-head"> <div class="card-head">
<div class="logo-chip logo-chip--sm"> <div class="logo-chip logo-chip--sm">
<img src="../../assets/images/logo/brand-icon.png" :alt="brand.nameEn" /> <img src="../../assets/images/logo/brand-logo.svg" :alt="brand.nameEn" />
</div> </div>
<span class="eyebrow"><span class="dot"></span>پنل مدیریت</span> <span class="eyebrow"><span class="dot"></span>پنل مدیریت</span>
</div> </div>
@@ -347,7 +347,8 @@ async function loginJWT() {
gap: 12px; gap: 12px;
} }
.logo-chip { .logo-chip {
width: 52px; /* wide: the mark is about 1.45:1 */
width: 72px;
height: 52px; height: 52px;
display: grid; display: grid;
place-items: center; place-items: center;
@@ -358,7 +359,7 @@ async function loginJWT() {
0 10px 30px -8px rgba(242, 201, 92, 0.45); 0 10px 30px -8px rgba(242, 201, 92, 0.45);
} }
.logo-chip img { .logo-chip img {
width: 64%; width: 82%;
} }
.brand-name { .brand-name {
font-size: 16px; font-size: 16px;
@@ -492,7 +493,7 @@ async function loginJWT() {
justify-content: space-between; justify-content: space-between;
} }
.logo-chip--sm { .logo-chip--sm {
width: 44px; width: 60px;
height: 44px; height: 44px;
border-radius: 14px; border-radius: 14px;
} }
+1 -1
View File
@@ -9,7 +9,7 @@
<div class="flex gap-6 w-full justify-center h-screen branch-login-page"> <div class="flex gap-6 w-full justify-center h-screen branch-login-page">
<div class="flex flex-col h-screen items-center justify-center"> <div class="flex flex-col h-screen items-center justify-center">
<img <img
src="../../assets/images/logo/brand-icon.png" src="../../assets/images/logo/brand-logo.svg"
width="150" width="150"
alt="login" alt="login"
class="mx-auto mb-5" class="mx-auto mb-5"
@@ -121,7 +121,7 @@
<ul> <ul>
<li class="comment-item"> <li class="comment-item">
<img src="../../../assets/images/logo/brand-icon.png" alt="comment author" class="img border" /> <img src="../../../assets/images/logo/brand-logo.svg" alt="comment author" class="img border" />
<div class="comment-body"> <div class="comment-body">
<h5>ادمین</h5> <h5>ادمین</h5>
<span v-if="tempRow.reply && tempRow.reply[0]" class="time"> <span v-if="tempRow.reply && tempRow.reply[0]" class="time">
@@ -271,6 +271,12 @@ async function submitChanges(row: Record<string, any>) {
border: 1px solid grey; border: 1px solid grey;
border-radius: 50%; border-radius: 50%;
} }
img.img {
object-fit: contain;
padding: 10px;
box-sizing: border-box;
background: #fff;
}
@media (max-width: 575px) { @media (max-width: 575px) {
.comment-item { .comment-item {
+14 -1
View File
@@ -142,6 +142,12 @@
un-checked-children="غیرفعال" un-checked-children="غیرفعال"
/> />
</a-form-item> </a-form-item>
<a-form-item label="نمایش به">
<a-radio-group v-model:value="customButton.audience">
<a-radio value="all">همه (قبل و بعد از ورود)</a-radio>
<a-radio value="loggedIn">فقط بعد از ورود</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="عنوان دکمه"> <a-form-item label="عنوان دکمه">
<a-input <a-input
v-model:value="customButton.title" v-model:value="customButton.title"
@@ -248,7 +254,12 @@ const tagsInputValue = ref('');
const sliders = ref<{ image: string; link: string }[]>([]); const sliders = ref<{ image: string; link: string }[]>([]);
// ── custom button (Setting key: custom_button) // ── custom button (Setting key: custom_button)
const customButton = ref({ enabled: false, title: '', link: '' }); const customButton = ref<{ enabled: boolean; title: string; link: string; audience: 'all' | 'loggedIn' }>({
enabled: false,
title: '',
link: '',
audience: 'all',
});
// ── collections // ── collections
const collections = ref<{ id: string; title: string }[]>([]); const collections = ref<{ id: string; title: string }[]>([]);
@@ -360,6 +371,7 @@ async function saveCustomButton() {
enabled: customButton.value.enabled, enabled: customButton.value.enabled,
title, title,
link, link,
audience: customButton.value.audience,
}, },
}); });
message.success('دکمه سفارشی ذخیره شد'); message.success('دکمه سفارشی ذخیره شد');
@@ -428,6 +440,7 @@ onMounted(async () => {
enabled: Boolean(btn.data.enabled), enabled: Boolean(btn.data.enabled),
title: String(btn.data.title ?? ''), title: String(btn.data.title ?? ''),
link: String(btn.data.link ?? ''), link: String(btn.data.link ?? ''),
audience: btn.data.audience === 'loggedIn' ? 'loggedIn' : 'all',
}; };
} }
} catch (e) { } catch (e) {
@@ -24,18 +24,23 @@ export class PublicSliderController {
return Array.isArray(value) ? value : []; return Array.isArray(value) ? value : [];
} }
/** Custom button on the profile dashboard (ثابت‌ها → دکمه سفارشی). */ /**
* Custom button on the profile dashboard (ثابت‌ها → دکمه سفارشی).
* `audience`: 'all' = signed out too, 'loggedIn' = only after login. Rows
* saved before the field existed were shown to everyone, so they read 'all'.
*/
@Get('custom-button') @Get('custom-button')
async getCustomButton() { async getCustomButton() {
const value = await this.misc.getConst('custom_button'); const value = await this.misc.getConst('custom_button');
if (!value || typeof value !== 'object' || Array.isArray(value)) { if (!value || typeof value !== 'object' || Array.isArray(value)) {
return { enabled: false, title: '', link: '' }; return { enabled: false, title: '', link: '', audience: 'all' };
} }
const row = value as Record<string, unknown>; const row = value as Record<string, unknown>;
return { return {
enabled: Boolean(row.enabled), enabled: Boolean(row.enabled),
title: String(row.title ?? ''), title: String(row.title ?? ''),
link: String(row.link ?? ''), link: String(row.link ?? ''),
audience: row.audience === 'loggedIn' ? 'loggedIn' : 'all',
}; };
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico?v=3" />
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 43 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 28 KiB

+2
View File
@@ -332,6 +332,8 @@ export interface CustomButton {
enabled: boolean; enabled: boolean;
title: string; title: string;
link: string; link: string;
/** 'all' = also before login; 'loggedIn' = only signed-in customers. Absent on older APIs = 'all'. */
audience?: 'all' | 'loggedIn';
} }
/** One admin-uploaded home banner slide — raw shape as saved by the admin /** One admin-uploaded home banner slide — raw shape as saved by the admin
Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 36 KiB

@@ -732,6 +732,11 @@ html.theme-dark body {
color: @ink; color: @ink;
} }
// The brand mark is black line art; on the dark canvas it is drawn light.
html.theme-dark .brand-mark {
filter: invert(1);
}
html.theme-dark { html.theme-dark {
.ant-input, .ant-input,
.ant-input-affix-wrapper, .ant-input-affix-wrapper,
+1 -1
View File
@@ -2,7 +2,7 @@
<template> <template>
<Spin :spinning="spinning" :tip="tip" :size="(size as 'small' | 'default' | 'large')" :delay="delay" :class="fixed ? '!max-h-screen !fixed' : ''"> <Spin :spinning="spinning" :tip="tip" :size="(size as 'small' | 'default' | 'large')" :delay="delay" :class="fixed ? '!max-h-screen !fixed' : ''">
<template #indicator> <template #indicator>
<img v-if="!indicator" src="/img/brand-logo.png" :alt="brand.nameEn" class="spinner-brand" width="48" height="48" /> <img v-if="!indicator" src="/img/brand-logo.svg" :alt="brand.nameEn" class="spinner-brand brand-mark" width="48" height="48" />
<!-- keep-motion: a working spinner, exempt from reduced motion. --> <!-- keep-motion: a working spinner, exempt from reduced motion. -->
<div class="loading keep-motion" v-else> <div class="loading keep-motion" v-else>
<div class="effect-1 effects"></div> <div class="effect-1 effects"></div>
@@ -22,10 +22,10 @@
> >
<img <img
class="rfx__logo" class="rfx__logo"
src="/img/brand-logo.png" src="/img/brand-logo-card.svg"
:alt="brand.name" :alt="brand.name"
width="140" width="168"
height="140" height="117"
/> />
<div class="rfx__spin" aria-hidden="true"> <div class="rfx__spin" aria-hidden="true">
<Loading type="spinner" size="28px" color="#fff" /> <Loading type="spinner" size="28px" color="#fff" />
@@ -88,8 +88,8 @@ const blocking = computed(() => props.intensity >= 0.99)
} }
.rfx__logo { .rfx__logo {
width: 140px; width: 168px;
height: 140px; height: 117px;
object-fit: contain; object-fit: contain;
filter: drop-shadow(0 22px 44px rgba(0, 0, 0, 0.5)) filter: drop-shadow(0 22px 44px rgba(0, 0, 0, 0.5))
drop-shadow(0 0 36px rgba(124, 0, 53, 0.4)); drop-shadow(0 0 36px rgba(124, 0, 53, 0.4));
+1 -1
View File
@@ -25,7 +25,7 @@ import { defineStore } from 'pinia'
import { api } from '../api/client' import { api } from '../api/client'
import shippingArt from '../assets/img/shipping-and-delivery.png' import shippingArt from '../assets/img/shipping-and-delivery.png'
import competitionArt from '../assets/img/sports-and-competition.png' import competitionArt from '../assets/img/sports-and-competition.png'
import brandArt from '../assets/img/brand-logo.png' import brandArt from '../assets/img/brand-logo.svg'
import { brand } from '../brand' import { brand } from '../brand'
export interface Slide { export interface Slide {
+1 -1
View File
@@ -22,7 +22,7 @@
<div class="home"> <div class="home">
<header class="home__bar"> <header class="home__bar">
<div class="home__barInner"> <div class="home__barInner">
<img src="/img/brand-logo.png" :alt="brand.name" class="home__logo" /> <img src="/img/brand-logo.svg" :alt="brand.name" class="home__logo brand-mark" />
<RateStrip :quote="goldPrice" compact class="home__rate" /> <RateStrip :quote="goldPrice" compact class="home__rate" />
</div> </div>
</header> </header>
@@ -116,7 +116,7 @@
<!-- Signed out: same card shell as the logged-in hero; logo + CTA only. --> <!-- Signed out: same card shell as the logged-in hero; logo + CTA only. -->
<section v-else class="dash__hero dash__invite dash__reveal" :style="revealDelay(0)"> <section v-else class="dash__hero dash__invite dash__reveal" :style="revealDelay(0)">
<img class="dash__inviteLogo" src="/img/brand-logo.png" :alt="brand.name" /> <img class="dash__inviteLogo" src="/img/brand-logo.svg" :alt="brand.name" />
<button type="button" class="dash__inviteCta" @click="router.push({ name: 'login' })"> <button type="button" class="dash__inviteCta" @click="router.push({ name: 'login' })">
ورود / ثبت‌نام ورود / ثبت‌نام
</button> </button>
@@ -141,25 +141,6 @@
</span> </span>
</button> </button>
<button
v-if="session.isLoggedIn && surveyMajorityHits.length"
type="button"
class="dash__banner dash__banner--survey dash__reveal"
:style="revealDelay(1)"
@click="goToDetails(8)"
>
<span class="dash__bannerIcon" aria-hidden="true">
<Icon icon="vuesax-linear:like-1" size="18" />
</span>
<span class="dash__bannerText">
<b>{{ surveyMajorityHits.length }} طرح به حد اکثریت رسید</b>
<small>{{ surveyMajorityHits[0]?.title || 'بدون عنوان' }}{{ surveyMajorityHits.length > 1 ? ' و …' : '' }}</small>
</span>
<span class="dash__bannerChevron" aria-hidden="true">
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
</span>
</button>
<!-- Settings-style rows: every destination the hero no longer carries <!-- Settings-style rows: every destination the hero no longer carries
as its own quick-action row. --> as its own quick-action row. -->
<section class="dash__rows dash__reveal" :style="revealDelay(1)"> <section class="dash__rows dash__reveal" :style="revealDelay(1)">
@@ -216,6 +197,7 @@
mcode, so every signed-in customer sees it and the server scopes it mcode, so every signed-in customer sees it and the server scopes it
to their token. to their token.
--> -->
<!-- «سفارش ساخت» hidden for now.
<button type="button" class="dash__row" @click="goToDetails(7)"> <button type="button" class="dash__row" @click="goToDetails(7)">
<span class="dash__rowIcon" aria-hidden="true"> <span class="dash__rowIcon" aria-hidden="true">
<Icon icon="vuesax-linear:3d-cube-scan" size="18" /> <Icon icon="vuesax-linear:3d-cube-scan" size="18" />
@@ -227,6 +209,7 @@
<Icon icon="vuesax-linear:arrow-left-1" size="16" /> <Icon icon="vuesax-linear:arrow-left-1" size="16" />
</span> </span>
</button> </button>
-->
<button type="button" class="dash__row" @click="goToDetails(8)"> <button type="button" class="dash__row" @click="goToDetails(8)">
<span class="dash__rowIcon" aria-hidden="true"> <span class="dash__rowIcon" aria-hidden="true">
@@ -354,7 +337,7 @@ import RateStrip from '../../components/home/RateStrip.vue'
import IslandSheet from '../../components/ui/IslandSheet.vue' import IslandSheet from '../../components/ui/IslandSheet.vue'
import { price } from '../../utils/filters' import { price } from '../../utils/filters'
import { useSessionStore } from '../../stores/session' import { useSessionStore } from '../../stores/session'
import { api, type CustomButton, type CustomerDetail, type GoldPriceQuote, type ProductSurveyRow } from '../../api/client' import { api, type CustomButton, type CustomerDetail, type GoldPriceQuote } from '../../api/client'
import { useCachedReads } from '../../api/cachedReads' import { useCachedReads } from '../../api/cachedReads'
import { KEYS } from '../../stores/queryCache' import { KEYS } from '../../stores/queryCache'
@@ -365,12 +348,12 @@ const reads = useCachedReads()
const customer = ref<CustomerDetail | null>(reads.peek<CustomerDetail>(KEYS.profile) ?? null) const customer = ref<CustomerDetail | null>(reads.peek<CustomerDetail>(KEYS.profile) ?? null)
const goldPrice = ref<GoldPriceQuote | null>(reads.peek<GoldPriceQuote>(KEYS.goldPrice) ?? null) const goldPrice = ref<GoldPriceQuote | null>(reads.peek<GoldPriceQuote>(KEYS.goldPrice) ?? null)
const customButton = ref<CustomButton | null>(reads.peek<CustomButton>(KEYS.customButton) ?? null) const customButton = ref<CustomButton | null>(reads.peek<CustomButton>(KEYS.customButton) ?? null)
const surveyMajorityHits = ref<ProductSurveyRow[]>([])
const refreshing = ref(false) const refreshing = ref(false)
const profileError = ref(false) const profileError = ref(false)
const showCustomButton = computed(() => { const showCustomButton = computed(() => {
const btn = customButton.value const btn = customButton.value
if (btn?.audience === 'loggedIn' && !session.isLoggedIn) return false
return Boolean(btn?.enabled && btn.title.trim() && btn.link.trim()) return Boolean(btn?.enabled && btn.title.trim() && btn.link.trim())
}) })
@@ -481,17 +464,6 @@ async function loadDashboard() {
} }
goldPrice.value = price.status === 'fulfilled' ? price.value : null goldPrice.value = price.status === 'fulfilled' ? price.value : null
if (btn.status === 'fulfilled') customButton.value = btn.value if (btn.status === 'fulfilled') customButton.value = btn.value
if (session.isLoggedIn) {
try {
const surveys = await api.listOpenProductSurveys()
surveyMajorityHits.value = surveys.majorityHits ?? surveys.rows.filter((r) => r.reachedMajority)
} catch {
surveyMajorityHits.value = []
}
} else {
surveyMajorityHits.value = []
}
} }
async function onRefresh() { async function onRefresh() {
@@ -984,11 +956,6 @@ onMounted(async () => {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.dash__banner--survey {
background: linear-gradient(115deg, @green 0%, #1f7a4d 100%);
box-shadow: 0 10px 24px -16px alpha(@green, 70%);
}
.dash__bannerChevron { .dash__bannerChevron {
flex: 0 0 auto; flex: 0 0 auto;
width: 28px; width: 28px;
@@ -23,7 +23,7 @@
<container class="auth__body"> <container class="auth__body">
<section class="auth__intro"> <section class="auth__intro">
<img class="auth__logo" src="/img/brand-logo.png" :alt="brand.name" /> <img class="auth__logo brand-mark" src="/img/brand-logo.svg" :alt="brand.name" />
<h2 class="auth__title">ورود به {{ brand.name }}</h2> <h2 class="auth__title">ورود به {{ brand.name }}</h2>
<p class="auth__sub"> <p class="auth__sub">
{{ body.step === 1 ? 'شماره موبایل خود را وارد کنید' : 'رمز یکبارمصرف را وارد کنید' }} {{ body.step === 1 ? 'شماره موبایل خود را وارد کنید' : 'رمز یکبارمصرف را وارد کنید' }}
+15 -7
View File
@@ -9,19 +9,27 @@
| نام فارسی، نام لاتین، عنوان تب مرورگر، آدرس سایت | [`brand.ts`](../brand.ts) در ریشهٔ پروژه | پنل ادمین، اپ مشتری و اپ مشتری v2 همه از همین یک فایل می‌خوانند: عنوان صفحه، نام نصب PWA، عنوان صفحهٔ ورود، پیام خوش‌آمد، متن‌های راهنما، لینک محصول. | | نام فارسی، نام لاتین، عنوان تب مرورگر، آدرس سایت | [`brand.ts`](../brand.ts) در ریشهٔ پروژه | پنل ادمین، اپ مشتری و اپ مشتری v2 همه از همین یک فایل می‌خوانند: عنوان صفحه، نام نصب PWA، عنوان صفحهٔ ورود، پیام خوش‌آمد، متن‌های راهنما، لینک محصول. |
| نام برند در **همهٔ پیامک‌ها** | متغیر محیطی `BRAND_NAME` روی سرور API | خالی باشد = «تهران تاج» (پیش‌فرض در [`apps/api/src/common/brand.ts`](../apps/api/src/common/brand.ts)). بعد از تغییر فقط API را ری‌استارت کنید. | | نام برند در **همهٔ پیامک‌ها** | متغیر محیطی `BRAND_NAME` روی سرور API | خالی باشد = «تهران تاج» (پیش‌فرض در [`apps/api/src/common/brand.ts`](../apps/api/src/common/brand.ts)). بعد از تغییر فقط API را ری‌استارت کنید. |
| لینک سایت در پیامک خوش‌آمد | متغیر محیطی `BRAND_SITE_URL` روی سرور API | پیش‌فرض فعلاً `https://bnk.montzar.ir` است (دامنهٔ زنده). | | لینک سایت در پیامک خوش‌آمد | متغیر محیطی `BRAND_SITE_URL` روی سرور API | پیش‌فرض فعلاً `https://bnk.montzar.ir` است (دامنهٔ زنده). |
| لوگو | فایل‌های تصویر با نام ثابت `brand-*` (پایین) | نام فایل‌ها عمومی است؛ فقط خود تصویر را با همان نام جایگزین کنید. | | لوگو | دو فایل SVG در ریشهٔ پروژه (پایین) | فقط همین دو فایل را عوض کنید و اسکریپت ساخت را اجرا کنید؛ بقیه از روی آن‌ها ساخته می‌شوند. |
### فایل‌های لوگو ### فایل‌های لوگو
- `apps/user-app-v2/public/img/brand-logo.png` و `.webp` منبع (ریشهٔ پروژه):
- `apps/user-app-v2/src/assets/img/brand-logo.png`
- `apps/admin-panel/src/assets/images/logo/brand-icon.png` و `.webp`، `brand-horizontal.png` - `logo.svg` — نقش مشکی روی زمینهٔ شفاف؛ در صفحه‌ها، favicon و آیکون PWA
- آیکون‌های PWA و favicon: `apps/*/public/icon-192.png`، `icon-512.png`، `favicon.*` - `logo_bk.svg` — همان نقش روی کارت سفید؛ برای زمینه‌های تیره (پردهٔ تازه‌سازی اپ)
- اسکریپت ساخت همهٔ این‌ها از یک تصویر: [`scripts/generate-brand-icons.py`](../scripts/generate-brand-icons.py) (مسیر تصویر ورودی را در خط اول آن عوض کنید)
ساخت همهٔ خروجی‌ها: `node scripts/generate-brand-icons.cjs` ([`scripts/generate-brand-icons.cjs`](../scripts/generate-brand-icons.cjs)). خروجی‌ها:
- `apps/user-app-v2/public/img/brand-logo.svg`، `brand-logo-card.svg`، و نسخهٔ `brand-logo.png`/`.webp` برای لینک‌های مستقیم قدیمی
- `apps/user-app-v2/src/assets/img/brand-logo.svg`
- `apps/admin-panel/src/assets/images/logo/brand-logo.svg`
- آیکون‌های PWA و favicon: `apps/user-app-v2/public/icon-192.png`، `icon-512.png`، `apps/*/public/favicon.*`
نقش مشکی است؛ در حالت تیرهٔ اپ مشتری، کلاس `brand-mark` آن را روشن نشان می‌دهد (`style.less`).
### بعد از تغییر ### بعد از تغییر
1. `dist` هر سه فرانت و `apps/api/release` را دوباره بسازید (طبق روال انتشار). 1. `dist` هر دو فرانت و `apps/api/release` را دوباره بسازید (طبق روال انتشار).
2. اگر در دیتابیس فعلی برندی به نام قدیمی در «برندهای محصول» هست، از پنل ادمین ویرایشش کنید (seed فقط دیتابیس خالی را پر می‌کند). 2. اگر در دیتابیس فعلی برندی به نام قدیمی در «برندهای محصول» هست، از پنل ادمین ویرایشش کنید (seed فقط دیتابیس خالی را پر می‌کند).
## ۲. عمداً عوض نشده‌اند (نام داخلی، دیده نمی‌شوند) ## ۲. عمداً عوض نشده‌اند (نام داخلی، دیده نمی‌شوند)
+42
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 36 KiB

+45
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 36 KiB

+95
View File
@@ -0,0 +1,95 @@
// Builds every brand image of the two frontends from the two source SVGs in
// the repo root (see docs/BRANDING.md):
// logo.svg black mark on transparent — pages, favicons, PWA icons
// logo_bk.svg the same mark on a white rounded card — dark backdrops
//
// node scripts/generate-brand-icons.cjs
//
// sharp is resolved from apps/api, which already depends on it.
const { createRequire } = require('node:module');
const { readFileSync, writeFileSync } = require('node:fs');
const { join } = require('node:path');
const root = join(__dirname, '..');
const sharp = createRequire(join(root, 'apps/api/package.json'))('sharp');
const userApp = join(root, 'apps/user-app-v2');
const admin = join(root, 'apps/admin-panel');
// Illustrator's "<title>Asset 1</title>" is noise in an <img>.
const clean = (file) => readFileSync(join(root, file), 'utf8').replace(/\s*<title>[^<]*<\/title>/, '');
const logo = clean('logo.svg');
const card = clean('logo_bk.svg');
/** The mark rendered `width` px wide, transparent. */
const mark = (width) => sharp(Buffer.from(logo), { density: 600 }).resize({ width }).png().toBuffer();
/**
* Square icon: the mark centred on white. `fill` is the mark's width as a
* share of the side; `radius` rounds the white square (0 = full bleed, as the
* OS masks PWA icons itself).
*/
async function icon(size, { fill, radius }) {
const r = Math.round(size * radius);
const plate = Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">` +
`<rect width="${size}" height="${size}" rx="${r}" ry="${r}" fill="#fff"/></svg>`,
);
return sharp(plate)
.composite([{ input: await mark(Math.round(size * fill)), gravity: 'center' }])
.png()
.toBuffer();
}
/** .ico holding PNG entries (supported by every current browser). */
function ico(pngs) {
const header = Buffer.alloc(6);
header.writeUInt16LE(1, 2);
header.writeUInt16LE(pngs.length, 4);
const entries = [];
let offset = 6 + 16 * pngs.length;
for (const { size, data } of pngs) {
const e = Buffer.alloc(16);
e.writeUInt8(size >= 256 ? 0 : size, 0);
e.writeUInt8(size >= 256 ? 0 : size, 1);
e.writeUInt16LE(1, 4);
e.writeUInt16LE(32, 6);
e.writeUInt32LE(data.length, 8);
e.writeUInt32LE(offset, 12);
offset += data.length;
entries.push(e);
}
return Buffer.concat([header, ...entries, ...pngs.map((p) => p.data)]);
}
function write(path, data) {
writeFileSync(path, data);
console.log('wrote', path.slice(root.length + 1));
}
(async () => {
write(join(userApp, 'public/img/brand-logo.svg'), logo);
write(join(userApp, 'public/img/brand-logo-card.svg'), card);
write(join(userApp, 'src/assets/img/brand-logo.svg'), logo);
write(join(admin, 'src/assets/images/logo/brand-logo.svg'), logo);
// Raster copies at the old public URLs, for anything linking them directly.
const big = await mark(1024);
write(join(userApp, 'public/img/brand-logo.png'), big);
write(join(userApp, 'public/img/brand-logo.webp'), await sharp(big).webp({ quality: 90 }).toBuffer());
// PWA: one file serves "any" and "maskable", so the mark stays inside the
// maskable safe circle (80% of the side): a 1.45:1 mark at 64% width does.
write(join(userApp, 'public/icon-192.png'), await icon(192, { fill: 0.64, radius: 0 }));
write(join(userApp, 'public/icon-512.png'), await icon(512, { fill: 0.64, radius: 0 }));
// Tab icons: white rounded chip, so the black mark shows on dark tab strips too.
const chip = { fill: 0.86, radius: 0.2 };
const favicon = ico(await Promise.all([16, 32, 48, 64].map(async (size) => ({ size, data: await icon(size, chip) }))));
write(join(userApp, 'public/favicon.ico'), favicon);
write(join(admin, 'public/favicon.ico'), favicon);
write(join(admin, 'public/favicon.png'), await icon(192, chip));
})().catch((e) => {
console.error(e);
process.exit(1);
});
-34
View File
@@ -1,34 +0,0 @@
from PIL import Image
from pathlib import Path
src = Path(r"C:\Users\mahmo\.cursor\projects\c-App-gold-bank\assets\c__Users_mahmo_AppData_Roaming_Cursor_User_workspaceStorage_e98278b20f306ff9e248e1d576e8bb3f_images_mzLogoDarkmode-079331ec-aa35-4a0f-bc63-1b0a0c4215eb.webp")
img = Image.open(src).convert("RGBA")
print("source size", img.size)
root = Path(r"C:\App\gold-bank")
(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)
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/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")
for size, path in [
(192, root / "apps/user-app/public/icon-192.png"),
(512, root / "apps/user-app/public/icon-512.png"),
]:
resized = img.resize((size, size), Image.Resampling.LANCZOS)
resized.save(path, "PNG")
print("wrote", path, size)
favicon = img.resize((256, 256), Image.Resampling.LANCZOS)
favicon.save(root / "apps/user-app/public/favicon.ico", sizes=[(16, 16), (32, 32), (48, 48), (64, 64)])
favicon.save(
root / "apps/admin-panel/src/assets/images/logo/favicon.ico",
sizes=[(16, 16), (32, 32), (48, 48), (64, 64)],
)
print("done")