fix(admin,frontend): fix hydration mismatch, admin UI texts sync, and product image resolution
Some checks failed
Deploy Canina / deploy (push) Failing after 1s
Some checks failed
Deploy Canina / deploy (push) Failing after 1s
This commit is contained in:
parent
3122c5b13f
commit
5ec99155c4
@ -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(
|
||||
|
||||
@ -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.`);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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} />);
|
||||
|
||||
@ -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) || [],
|
||||
|
||||
Loading…
Reference in New Issue
Block a user