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>
@@ -3,8 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png?v=2" />
|
||||
<link rel="shortcut icon" href="/favicon.ico?v=2" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png?v=3" />
|
||||
<link rel="shortcut icon" href="/favicon.ico?v=3" />
|
||||
<title>%BRAND_TITLE%</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 37 KiB |
@@ -19,7 +19,7 @@
|
||||
>
|
||||
<div class="nav-header" :class="{ 'is-collapsed': collapsed }">
|
||||
<span class="nav-logo">
|
||||
<img src="../../assets/images/logo/brand-icon.png" alt="" />
|
||||
<img src="../../assets/images/logo/brand-logo.svg" alt="" />
|
||||
</span>
|
||||
<span v-if="!collapsed" class="nav-brand">
|
||||
<span class="nav-title">{{ title }}</span>
|
||||
@@ -207,8 +207,9 @@ function onClick({ key }: { key: string | number }) {
|
||||
padding: 0;
|
||||
}
|
||||
.nav-logo {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
/* wide: the mark is about 1.45:1 */
|
||||
width: 54px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
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);
|
||||
}
|
||||
.nav-logo img {
|
||||
width: 64%;
|
||||
width: 86%;
|
||||
}
|
||||
.nav-brand {
|
||||
display: flex;
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<section class="hero" aria-hidden="true">
|
||||
<div class="brand-row">
|
||||
<div class="logo-chip">
|
||||
<img src="../../assets/images/logo/brand-icon.png" alt="" />
|
||||
<img src="../../assets/images/logo/brand-logo.svg" alt="" />
|
||||
</div>
|
||||
<span class="brand-name">{{ brand.nameEn }}</span>
|
||||
</div>
|
||||
@@ -83,7 +83,7 @@
|
||||
<div class="glass-card">
|
||||
<div class="card-head">
|
||||
<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>
|
||||
<span class="eyebrow"><span class="dot"></span>پنل مدیریت</span>
|
||||
</div>
|
||||
@@ -347,7 +347,8 @@ async function loginJWT() {
|
||||
gap: 12px;
|
||||
}
|
||||
.logo-chip {
|
||||
width: 52px;
|
||||
/* wide: the mark is about 1.45:1 */
|
||||
width: 72px;
|
||||
height: 52px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -358,7 +359,7 @@ async function loginJWT() {
|
||||
0 10px 30px -8px rgba(242, 201, 92, 0.45);
|
||||
}
|
||||
.logo-chip img {
|
||||
width: 64%;
|
||||
width: 82%;
|
||||
}
|
||||
.brand-name {
|
||||
font-size: 16px;
|
||||
@@ -492,7 +493,7 @@ async function loginJWT() {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.logo-chip--sm {
|
||||
width: 44px;
|
||||
width: 60px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<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">
|
||||
<img
|
||||
src="../../assets/images/logo/brand-icon.png"
|
||||
src="../../assets/images/logo/brand-logo.svg"
|
||||
width="150"
|
||||
alt="login"
|
||||
class="mx-auto mb-5"
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
|
||||
<ul>
|
||||
<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">
|
||||
<h5>ادمین</h5>
|
||||
<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-radius: 50%;
|
||||
}
|
||||
img.img {
|
||||
object-fit: contain;
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.comment-item {
|
||||
|
||||
@@ -142,6 +142,12 @@
|
||||
un-checked-children="غیرفعال"
|
||||
/>
|
||||
</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-input
|
||||
v-model:value="customButton.title"
|
||||
@@ -248,7 +254,12 @@ const tagsInputValue = ref('');
|
||||
const sliders = ref<{ image: string; link: string }[]>([]);
|
||||
|
||||
// ── 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
|
||||
const collections = ref<{ id: string; title: string }[]>([]);
|
||||
@@ -360,6 +371,7 @@ async function saveCustomButton() {
|
||||
enabled: customButton.value.enabled,
|
||||
title,
|
||||
link,
|
||||
audience: customButton.value.audience,
|
||||
},
|
||||
});
|
||||
message.success('دکمه سفارشی ذخیره شد');
|
||||
@@ -428,6 +440,7 @@ onMounted(async () => {
|
||||
enabled: Boolean(btn.data.enabled),
|
||||
title: String(btn.data.title ?? ''),
|
||||
link: String(btn.data.link ?? ''),
|
||||
audience: btn.data.audience === 'loggedIn' ? 'loggedIn' : 'all',
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -24,18 +24,23 @@ export class PublicSliderController {
|
||||
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')
|
||||
async getCustomButton() {
|
||||
const value = await this.misc.getConst('custom_button');
|
||||
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>;
|
||||
return {
|
||||
enabled: Boolean(row.enabled),
|
||||
title: String(row.title ?? ''),
|
||||
link: String(row.link ?? ''),
|
||||
audience: row.audience === 'loggedIn' ? 'loggedIn' : 'all',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="icon" href="/favicon.ico?v=3" />
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 28 KiB |
@@ -332,6 +332,8 @@ export interface CustomButton {
|
||||
enabled: boolean;
|
||||
title: 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
|
||||
|
||||
|
Before Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 36 KiB |
@@ -732,6 +732,11 @@ html.theme-dark body {
|
||||
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 {
|
||||
.ant-input,
|
||||
.ant-input-affix-wrapper,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<template>
|
||||
<Spin :spinning="spinning" :tip="tip" :size="(size as 'small' | 'default' | 'large')" :delay="delay" :class="fixed ? '!max-h-screen !fixed' : ''">
|
||||
<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. -->
|
||||
<div class="loading keep-motion" v-else>
|
||||
<div class="effect-1 effects"></div>
|
||||
|
||||
@@ -22,10 +22,10 @@
|
||||
>
|
||||
<img
|
||||
class="rfx__logo"
|
||||
src="/img/brand-logo.png"
|
||||
src="/img/brand-logo-card.svg"
|
||||
:alt="brand.name"
|
||||
width="140"
|
||||
height="140"
|
||||
width="168"
|
||||
height="117"
|
||||
/>
|
||||
<div class="rfx__spin" aria-hidden="true">
|
||||
<Loading type="spinner" size="28px" color="#fff" />
|
||||
@@ -88,8 +88,8 @@ const blocking = computed(() => props.intensity >= 0.99)
|
||||
}
|
||||
|
||||
.rfx__logo {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
width: 168px;
|
||||
height: 117px;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 22px 44px rgba(0, 0, 0, 0.5))
|
||||
drop-shadow(0 0 36px rgba(124, 0, 53, 0.4));
|
||||
|
||||
@@ -25,7 +25,7 @@ import { defineStore } from 'pinia'
|
||||
import { api } from '../api/client'
|
||||
import shippingArt from '../assets/img/shipping-and-delivery.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'
|
||||
|
||||
export interface Slide {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<div class="home">
|
||||
<header class="home__bar">
|
||||
<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" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
|
||||
<!-- 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)">
|
||||
<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>
|
||||
@@ -141,25 +141,6 @@
|
||||
</span>
|
||||
</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
|
||||
as its own quick-action row. -->
|
||||
<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
|
||||
to their token.
|
||||
-->
|
||||
<!-- «سفارش ساخت» hidden for now.
|
||||
<button type="button" class="dash__row" @click="goToDetails(7)">
|
||||
<span class="dash__rowIcon" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:3d-cube-scan" size="18" />
|
||||
@@ -227,6 +209,7 @@
|
||||
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
|
||||
</span>
|
||||
</button>
|
||||
-->
|
||||
|
||||
<button type="button" class="dash__row" @click="goToDetails(8)">
|
||||
<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 { price } from '../../utils/filters'
|
||||
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 { KEYS } from '../../stores/queryCache'
|
||||
|
||||
@@ -365,12 +348,12 @@ const reads = useCachedReads()
|
||||
const customer = ref<CustomerDetail | null>(reads.peek<CustomerDetail>(KEYS.profile) ?? null)
|
||||
const goldPrice = ref<GoldPriceQuote | null>(reads.peek<GoldPriceQuote>(KEYS.goldPrice) ?? null)
|
||||
const customButton = ref<CustomButton | null>(reads.peek<CustomButton>(KEYS.customButton) ?? null)
|
||||
const surveyMajorityHits = ref<ProductSurveyRow[]>([])
|
||||
const refreshing = ref(false)
|
||||
const profileError = ref(false)
|
||||
|
||||
const showCustomButton = computed(() => {
|
||||
const btn = customButton.value
|
||||
if (btn?.audience === 'loggedIn' && !session.isLoggedIn) return false
|
||||
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
|
||||
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() {
|
||||
@@ -984,11 +956,6 @@ onMounted(async () => {
|
||||
overflow: hidden;
|
||||
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 {
|
||||
flex: 0 0 auto;
|
||||
width: 28px;
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
<container class="auth__body">
|
||||
<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>
|
||||
<p class="auth__sub">
|
||||
{{ body.step === 1 ? 'شماره موبایل خود را وارد کنید' : 'رمز یکبارمصرف را وارد کنید' }}
|
||||
|
||||
@@ -9,19 +9,27 @@
|
||||
| نام فارسی، نام لاتین، عنوان تب مرورگر، آدرس سایت | [`brand.ts`](../brand.ts) در ریشهٔ پروژه | پنل ادمین، اپ مشتری و اپ مشتری v2 همه از همین یک فایل میخوانند: عنوان صفحه، نام نصب PWA، عنوان صفحهٔ ورود، پیام خوشآمد، متنهای راهنما، لینک محصول. |
|
||||
| نام برند در **همهٔ پیامکها** | متغیر محیطی `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-*` (پایین) | نام فایلها عمومی است؛ فقط خود تصویر را با همان نام جایگزین کنید. |
|
||||
| لوگو | دو فایل 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`
|
||||
- آیکونهای PWA و favicon: `apps/*/public/icon-192.png`، `icon-512.png`، `favicon.*`
|
||||
- اسکریپت ساخت همهٔ اینها از یک تصویر: [`scripts/generate-brand-icons.py`](../scripts/generate-brand-icons.py) (مسیر تصویر ورودی را در خط اول آن عوض کنید)
|
||||
منبع (ریشهٔ پروژه):
|
||||
|
||||
- `logo.svg` — نقش مشکی روی زمینهٔ شفاف؛ در صفحهها، favicon و آیکون PWA
|
||||
- `logo_bk.svg` — همان نقش روی کارت سفید؛ برای زمینههای تیره (پردهٔ تازهسازی اپ)
|
||||
|
||||
ساخت همهٔ خروجیها: `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 فقط دیتابیس خالی را پر میکند).
|
||||
|
||||
## ۲. عمداً عوض نشدهاند (نام داخلی، دیده نمیشوند)
|
||||
|
||||
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |
@@ -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);
|
||||
});
|
||||
@@ -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")
|
||||