fix(admin,frontend): fix hydration mismatch, admin UI texts sync, and product image resolution
Some checks failed
Deploy Canina / deploy (push) Failing after 1s

This commit is contained in:
parsa aghaei 2026-08-15 15:16:04 +03:30
parent 3122c5b13f
commit 5ec99155c4
7 changed files with 103 additions and 31 deletions

View File

@ -50,16 +50,24 @@ function getProductImageUrl(baseSlug: string): string {
'https://www.canina.de/media/01/be/93/710003_Flexan_Canina-Pharma_1280x1280.png',
'canina-herz-vital':
'https://www.canina.de/media/b9/8b/4c/112036_Herz_Vital_Canina-Pharma_1280x1280.png',
'canina-immun-booster':
'https://www.canina.de/media/8c/fb/8e/311000_Canino_Immun_Booster_Paste_Abwehrkraefte_1280x1280.png',
'canina-immun-booster-paste':
'https://www.canina.de/media/8c/fb/8e/311000_Canino_Immun_Booster_Paste_Abwehrkraefte_1280x1280.png',
'canina-katzenmilch':
'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
'canina-lachsol':
'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
'canina-lachs-l':
'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
'canina-lachs-ol':
'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
'canina-marine-olmischung-premium':
'https://www.canina.de/media/ad/28/71/153008_Marine_Oelmischung_Premium_Canina-Pharma_1280x1280.png',
'canina-marine-lmischung-premium':
'https://www.canina.de/media/ad/28/71/153008_Marine_Oelmischung_Premium_Canina-Pharma_1280x1280.png',
'canina-moortranke':
'https://www.canina.de/media/9d/b2/87/112708_Moortraenke_Canina-Pharma_1280x1280.png',
'canina-moortrnke':
'https://www.canina.de/media/9d/b2/87/112708_Moortraenke_Canina-Pharma_1280x1280.png',
'canina-petvital-arthro-tabletten':
@ -80,10 +88,20 @@ function getProductImageUrl(baseSlug: string): string {
'https://www.canina.de/media/14/d0/0d/792016_791514_Rinderblut_Pulver_Canina-Pharma_1280x1280.png',
'canina-rinderfett-pulver':
'https://www.canina.de/media/9a/31/59/131235_Rinderfett_Pulver_Canina-Pharma_1280x1280.png',
'canina-schwarz-kummel-samen':
'https://www.canina.de/media/a9/c8/aa/131105_Schwarzkuemmelsamen_Canina-Pharma_1280x1280.png',
'canina-schwarz-kmmel-samen':
'https://www.canina.de/media/a9/c8/aa/131105_Schwarzkuemmelsamen_Canina-Pharma_1280x1280.png',
'canina-seelgen-tabletten':
'https://www.canina.de/media/e4/c4/b2/130504_130511_130412_Seealgen_Bio-Seealgenmehl_Canina-Pharma_1280x1280.png',
'canina-bio-seelgenmehl':
'https://www.canina.de/media/e4/c4/b2/130504_130511_130412_Seealgen_Bio-Seealgenmehl_Canina-Pharma_1280x1280.png',
'canina-seealgen-tabletten':
'https://www.canina.de/media/e4/c4/b2/130504_130511_130412_Seealgen_Bio-Seealgenmehl_Canina-Pharma_1280x1280.png',
'canina-seealgen-bio-seealgenmehl':
'https://www.canina.de/media/e4/c4/b2/130504_130511_130412_Seealgen_Bio-Seealgenmehl_Canina-Pharma_1280x1280.png',
'canina-taurin-fur-katzen':
'https://www.canina.de/media/c6/aa/62/229505_Taurin_fuer_Katzen_Canina-Pharma_1280x1280.png',
'canina-taurin-fr-katzen':
'https://www.canina.de/media/c6/aa/62/229505_Taurin_fuer_Katzen_Canina-Pharma_1280x1280.png',
'canina-velox-gelenkenergie':
@ -101,7 +119,10 @@ function getProductImageUrl(baseSlug: string): string {
'canina-novagard-green-pfotenpflege':
'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
};
return images[baseSlug] || `/products/${baseSlug}.png`;
return (
images[baseSlug] ||
'https://www.canina.de/media/83/86/e1/123000_123005_Canhydrox_GAG_Canina-Pharma_1280x1280.png'
);
}
async function findOrCreateCategory(

View File

@ -3,6 +3,13 @@ import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const UI_TEXTS: Record<string, string> = {
// === Catalog & Maintenance Mode ===
"maintenance_mode": "false",
"catalog_mode": "false",
"catalog_hide_prices": "false",
"catalog_disable_cart": "false",
"catalog_disable_checkout": "false",
// === Header / Navigation ===
"shipping_notice": "ارسال رایگان برای سفارش‌های بالای ۱۵۰ هزار تومان",
"brand_name_fa": "ایران",
@ -131,10 +138,11 @@ async function main() {
const entries = Object.entries(UI_TEXTS);
for (const [key, value] of entries) {
const existing = await prisma.uiText.findUnique({ where: { key } });
if (!existing) {
await prisma.uiText.create({ data: { key, value } });
}
await prisma.uiText.upsert({
where: { key },
update: { value },
create: { key, value },
});
}
console.log(`Seeded ${entries.length} UI texts.`);

View File

@ -39,7 +39,8 @@ export class SettingsController {
@Roles('Admin')
@ApiBearerAuth()
@Patch('ui-texts/:key')
@ApiOperation({ summary: 'ویرایش متن یک کلید در رابط کاربری' })
@Put('ui-texts/:key')
@ApiOperation({ summary: 'ویرایش یا ثبت متن یک کلید در رابط کاربری' })
updateUiText(@Param('key') key: string, @Body('value') value: string) {
return this.settingsService.updateUiText(key, value);
}

View File

@ -99,7 +99,19 @@ export default function UITexts() {
useEffect(() => {
let isSubscribed = true;
api.get('/settings/ui-texts').then(res => {
if (isSubscribed) setTexts(res.data || {});
if (isSubscribed) {
const textsObj: Record<string, string> = {};
if (Array.isArray(res.data)) {
res.data.forEach((item: { key?: string; value?: string }) => {
if (item && item.key) {
textsObj[item.key] = typeof item.value === 'string' ? item.value : (item.value ? JSON.stringify(item.value) : '');
}
});
} else if (typeof res.data === 'object' && res.data !== null) {
Object.assign(textsObj, res.data);
}
setTexts(textsObj);
}
}).catch(e => {
console.error('Failed to load UI texts:', e);
}).finally(() => {

View File

@ -38,11 +38,21 @@ function StatCounter({ target }: { target: number }) {
import { useRouter } from 'next/navigation';
import { Banner } from "../lib/types";
export default function Hero({ banners = [] }: { banners?: Banner[] }) {
export default function Hero({ banners = [], onShopNavigate }: { banners?: Banner[]; onShopNavigate?: () => void }) {
const router = useRouter();
const { getText, isInitialized } = useSettingsStore();
const title = (isInitialized ? getText('hero_title', '') : '') || (banners.length > 0 && banners[0].title ? banners[0].title : "تخصص آلمانی در خدمت\nسلامت پت‌های خانگی");
const subtitle = (isInitialized ? getText('hero_desc', '') : '') || (banners.length > 0 && banners[0].subtitle ? banners[0].subtitle : 'از سال ۱۹۸۴، شرکت Canina pharma GmbH در برگیش گلادباخ آلمان، با بهره‌گیری از مواد اولیه طبیعی و فرآیندهای پیشرفته، استاندارد طلایی مکمل‌های دامپزشکی را تعریف می‌کند. کانینا نماینده رسمی این برند در ایران است.');
const getText = useSettingsStore((state) => state?.getText || ((_k: string, fb: string) => fb));
const isInitialized = useSettingsStore((state) => state?.isInitialized ?? false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const defaultTitle = banners.length > 0 && banners[0].title ? banners[0].title : "تخصص آلمانی در خدمت\nسلامت پت‌های خانگی";
const defaultSubtitle = banners.length > 0 && banners[0].subtitle ? banners[0].subtitle : 'از سال ۱۹۸۴، شرکت Canina pharma GmbH در برگیش گلادباخ آلمان، با بهره‌گیری از مواد اولیه طبیعی و فرآیندهای پیشرفته، استاندارد طلایی مکمل‌های دامپزشکی را تعریف می‌کند. کانینا نماینده رسمی این برند در ایران است.';
const title = (mounted && isInitialized ? getText('hero_title', '') : '') || defaultTitle;
const subtitle = (mounted && isInitialized ? getText('hero_desc', '') : '') || defaultSubtitle;
const titleParts = title.split('\n');
return (
@ -64,7 +74,7 @@ export default function Hero({ banners = [] }: { banners?: Banner[] }) {
<div className="inline-flex items-center gap-2 px-3 py-1 bg-canina-blue/5 border border-canina-blue/10 rounded-full mb-6">
<span className="w-2 h-2 rounded-full bg-canina-blue animate-pulse" />
<span className="text-canina-blue text-xs font-bold uppercase tracking-widest leading-none">
{isInitialized ? getText('hero_badge', "از ۱۹۸۴ در خدمت سلامت حیوانات") : "از ۱۹۸۴ در خدمت سلامت حیوانات"}
{mounted && isInitialized ? getText('hero_badge', "از ۱۹۸۴ در خدمت سلامت حیوانات") : "از ۱۹۸۴ در خدمت سلامت حیوانات"}
</span>
</div>
@ -90,14 +100,20 @@ export default function Hero({ banners = [] }: { banners?: Banner[] }) {
}}
className="bg-canina-blue text-white px-10 py-5 rounded-full font-bold text-lg hover:bg-canina-blue/90 hover:shadow-2xl focus-visible:ring-4 focus-visible:ring-canina-blue/30 focus-visible:outline-none transition-all flex items-center gap-2 group font-vazir min-h-[58px] shadow-lg shadow-canina-blue/20 cursor-pointer"
>
{isInitialized ? getText('hero_btn_advisor', "دستیار سلامت پت") : "دستیار سلامت پت"}
{mounted && isInitialized ? getText('hero_btn_advisor', "دستیار سلامت پت") : "دستیار سلامت پت"}
<ChevronLeft className="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
</button>
<button
onClick={() => router.push('/shop')}
onClick={() => {
if (onShopNavigate) {
onShopNavigate();
} else {
router.push('/shop');
}
}}
className="bg-white border-2 border-medical-gray-900 text-medical-gray-900 px-10 py-5 rounded-full font-bold text-lg hover:bg-medical-gray-50 focus-visible:ring-4 focus-visible:ring-canina-blue/30 focus-visible:outline-none transition-all font-vazir min-h-[58px] shadow-sm cursor-pointer"
>
{getText('hero_btn_products', "مشاهده محصولات")}
{mounted && isInitialized ? getText('hero_btn_products', "مشاهده محصولات") : "مشاهده محصولات"}
</button>
</div>
</motion.div>
@ -113,15 +129,19 @@ export default function Hero({ banners = [] }: { banners?: Banner[] }) {
>
<div className="aspect-square bg-gradient-to-br from-slate-200 to-slate-300 rounded-[2.5rem] overflow-hidden border-4 border-white shadow-2xl relative">
<img
src={getText('hero_image_url', '') || (banners.length > 0 && banners[0].imageUrl ? banners[0].imageUrl : "/assets/images/hero-section-image.png")}
src={(mounted && isInitialized ? getText('hero_image_url', '') : '') || (banners.length > 0 && banners[0].imageUrl ? banners[0].imageUrl : "/assets/images/hero-section-image.png")}
alt={banners.length > 0 ? banners[0].title : "Canina Pharma Germany"}
className="w-full h-full object-cover"
loading="eager"
referrerPolicy="no-referrer"
/>
<div className="absolute bottom-0 left-0 right-0 p-8 bg-gradient-to-t from-black/80 to-transparent text-white text-right">
<div className="text-sm font-medium mb-1 opacity-80 uppercase tracking-widest text-[10px]">{getText('hero_image_badge', "سرآمد علمی در پزشکی پت‌ها")}</div>
<div className="text-xl font-bold italic tracking-tighter">{getText('hero_image_title', "مکمل‌های تایید شده دامپزشکی")}</div>
<div className="text-sm font-medium mb-1 opacity-80 uppercase tracking-widest text-[10px]">
{mounted && isInitialized ? getText('hero_image_badge', "سرآمد علمی در پزشکی پت‌ها") : "سرآمد علمی در پزشکی پت‌ها"}
</div>
<div className="text-xl font-bold italic tracking-tighter">
{mounted && isInitialized ? getText('hero_image_title', "مکمل‌های تایید شده دامپزشکی با گواهی IFS") : "مکمل‌های تایید شده دامپزشکی با گواهی IFS"}
</div>
</div>
{/* Quality Badge inside the image container to prevent text collision */}
@ -132,7 +152,9 @@ export default function Hero({ banners = [] }: { banners?: Banner[] }) {
>
<div className="flex flex-col items-center">
<span className="text-xl lg:text-2xl font-black text-canina-blue leading-none tracking-tighter">DE</span>
<span className="text-[7px] lg:text-[8px] font-bold text-medical-gray-500 uppercase tracking-widest mt-0.5 leading-tight">{getText('hero_quality_standard', "استاندارد کیفی آلمان")}</span>
<span className="text-[7px] lg:text-[8px] font-bold text-medical-gray-500 uppercase tracking-widest mt-0.5 leading-tight">
{mounted && isInitialized ? getText('hero_quality_standard', "استاندارد کیفی آلمان (IFS & HACCP)") : "استاندارد کیفی آلمان (IFS & HACCP)"}
</span>
</div>
</motion.div>
@ -161,7 +183,9 @@ export default function Hero({ banners = [] }: { banners?: Banner[] }) {
</div>
<div className="flex flex-col items-center sm:items-start text-center sm:text-right">
<div className="text-base sm:text-3xl font-black text-canina-blue font-vazir leading-none">۱۹۸۴</div>
<div className="text-[9px] sm:text-xs text-medical-gray-500 font-extrabold uppercase tracking-tight mt-1 sm:mt-2 font-vazir">{getText('hero_stat_founded', "سال تاسیس برند")}</div>
<div className="text-[9px] sm:text-xs text-medical-gray-500 font-extrabold uppercase tracking-tight mt-1 sm:mt-2 font-vazir">
{mounted && isInitialized ? getText('hero_stat_founded', "سال تاسیس برند") : "سال تاسیس برند"}
</div>
</div>
</div>
@ -174,7 +198,9 @@ export default function Hero({ banners = [] }: { banners?: Banner[] }) {
<div className="flex items-baseline justify-center sm:justify-start">
<StatCounter target={40} />
</div>
<div className="text-[9px] sm:text-xs text-medical-gray-500 font-extrabold uppercase tracking-tight mt-1 sm:mt-2 font-vazir">{getText('hero_stat_agencies', "نمایندگی فعال")}</div>
<div className="text-[9px] sm:text-xs text-medical-gray-500 font-extrabold uppercase tracking-tight mt-1 sm:mt-2 font-vazir">
{mounted && isInitialized ? getText('hero_stat_agencies', "نمایندگی فعال") : "نمایندگی فعال"}
</div>
</div>
</div>
@ -185,7 +211,9 @@ export default function Hero({ banners = [] }: { banners?: Banner[] }) {
</div>
<div className="flex flex-col items-center sm:items-start text-center sm:text-right">
<div className="text-base sm:text-3xl font-black text-canina-blue font-vazir leading-none">۱۰۰٪</div>
<div className="text-[9px] sm:text-xs text-medical-gray-500 font-extrabold uppercase tracking-tight mt-1 sm:mt-2 font-vazir">{getText('hero_stat_german_formula', "فرمول آلمانی")}</div>
<div className="text-[9px] sm:text-xs text-medical-gray-500 font-extrabold uppercase tracking-tight mt-1 sm:mt-2 font-vazir">
{mounted && isInitialized ? getText('hero_stat_german_formula', "فرمول آلمانی") : "فرمول آلمانی"}
</div>
</div>
</div>
</motion.div>

View File

@ -14,25 +14,25 @@ describe('Hero', () => {
vi.clearAllMocks();
});
it('renders title and description from settingsStore', () => {
it('renders title and description from settingsStore', async () => {
const mockGetText = vi.fn().mockImplementation((key, fallback) => {
if (key === 'hero_title') return 'Test Title Line 1\nTest Title Line 2';
if (key === 'hero_desc') return 'Test Description Text';
return fallback;
});
vi.mocked(useSettingsStore).mockImplementation((selector: (state: { getText: (key: string, fallback: string) => string }) => unknown) => selector({ getText: mockGetText }) as ReturnType<typeof selector>);
vi.mocked(useSettingsStore).mockImplementation((selector: (state: { getText: (key: string, fallback: string) => string; isInitialized: boolean }) => unknown) => selector({ getText: mockGetText, isInitialized: true }) as ReturnType<typeof selector>);
render(<Hero onShopNavigate={vi.fn()} />);
expect(screen.getByText('Test Title Line 1')).toBeInTheDocument();
expect(screen.getByText('Test Title Line 2')).toBeInTheDocument();
expect(screen.getByText('Test Description Text')).toBeInTheDocument();
expect(await screen.findByText('Test Title Line 1')).toBeInTheDocument();
expect(await screen.findByText('Test Title Line 2')).toBeInTheDocument();
expect(await screen.findByText('Test Description Text')).toBeInTheDocument();
});
it('calls onShopNavigate when "مشاهده محصولات" button is clicked', () => {
const mockGetText = vi.fn().mockImplementation((_key, fallback) => fallback);
vi.mocked(useSettingsStore).mockImplementation((selector: (state: { getText: (key: string, fallback: string) => string }) => unknown) => selector({ getText: mockGetText }) as ReturnType<typeof selector>);
vi.mocked(useSettingsStore).mockImplementation((selector: (state: { getText: (key: string, fallback: string) => string; isInitialized: boolean }) => unknown) => selector({ getText: mockGetText, isInitialized: true }) as ReturnType<typeof selector>);
const handleShopNavigate = vi.fn();
render(<Hero onShopNavigate={handleShopNavigate} />);

View File

@ -121,9 +121,11 @@ export class ProductService {
optimisticTemplate: data.optimisticTemplate,
feedingAdvice: data.dosageLogic || data.feedingAdvice || '',
slug: data.slug,
image: (data.imageUrl && data.imageUrl.startsWith('http'))
? data.imageUrl
: (local?.image || (data.imageUrl ? `/api${data.imageUrl}` : data.image || '')),
image: (data.imageUrl && (data.imageUrl.startsWith('http://') || data.imageUrl.startsWith('https://')))
? data.imageUrl
: (data.imageUrl && data.imageUrl.startsWith('/uploads/')
? data.imageUrl
: (local?.image || (data.imageUrl && !data.imageUrl.startsWith('/products/') ? data.imageUrl : '') || '/assets/images/hero-section-image.png')),
main_ingredients: data.ingredientList?.map(i => i.ingredient) || data.ingredients?.split(/[،,-]/).map(s => s.trim()).filter(Boolean) || [],
symptoms: data.symptoms?.map(s => typeof s === 'string' ? s : s.symptom || '').filter(Boolean) || [],