Compare commits

..

No commits in common. "8d6d7d6a53d3661eaff4d4d1843f76f951398551" and "75f35b81833829224d529933288d8fbd5023bb52" have entirely different histories.

27 changed files with 104 additions and 195 deletions

View File

@ -1,5 +1,7 @@
node_modules
# dist
.env
.git
.dockerignore
Dockerfile
backend

View File

@ -5,10 +5,18 @@ on:
jobs:
deploy:
runs-on: canina
steps:
- name: Setup SSH key
runs-on: ubuntu-latest
defaults:
run:
shell: sh
steps:
- name: Install SSH client
run: |
V=$(cut -d'.' -f1-2 /etc/alpine-release)
echo "https://dl-cdn.alpinelinux.org/alpine/v$V/main" > /etc/apk/repositories
echo "https://dl-cdn.alpinelinux.org/alpine/v$V/community" >> /etc/apk/repositories
apk add --no-cache openssh-client
- name: Setup SSH key
env:
SSH_KEY: ${{ secrets.DEPLOY_KEY }}
run: |
@ -17,5 +25,4 @@ jobs:
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -p 2234 -H 172.17.0.1 >> ~/.ssh/known_hosts
- name: Deploy
shell: sh
run: ssh -p 2234 devops@172.17.0.1 'bash /opt/services/scripts/depcanina.sh main'

View File

@ -2,7 +2,7 @@ FROM node:22-alpine AS app-builder
RUN apk add --no-cache openssl
WORKDIR /app
COPY frontend/application/package*.json ./
RUN npm config set registry http://registry.parsaaghayi.ir/repository/npm-proxy/ && npm ci
RUN npm ci
COPY frontend/application ./
RUN npm run build
@ -10,16 +10,15 @@ FROM node:22-alpine AS admin-builder
RUN apk add --no-cache openssl
WORKDIR /app
COPY frontend/admin-panel/package*.json ./
RUN npm config set registry http://registry.parsaaghayi.ir/repository/npm-proxy/ && npm ci
RUN npm ci
COPY frontend/admin-panel ./
RUN npm run build
FROM node:22-alpine
RUN apk add --no-cache nginx
WORKDIR /app
COPY --from=app-builder /app .
COPY --from=admin-builder /app/dist ./admin_static
COPY nginx.conf /etc/nginx/http.d/default.conf
FROM nginxinc/nginx-unprivileged:alpine
WORKDIR /usr/share/nginx/html
COPY --from=app-builder /app/.next ./.next
COPY --from=app-builder /app/public ./public
COPY --from=admin-builder /app/dist ./admin
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8080
CMD ["sh", "-c", "nginx && npm start"]
CMD ["nginx", "-g", "daemon off;"]

View File

@ -1,3 +1,4 @@
node_modules
dist
.env
.git

View File

@ -1,20 +1,28 @@
FROM node:22-alpine AS builder
RUN apk add --no-cache openssl
WORKDIR /app
COPY package*.json ./
RUN npm config set registry http://registry.parsaaghayi.ir/repository/npm-proxy/ && npm ci
RUN npm ci
COPY . .
RUN npx prisma generate && npm run build
FROM node:22-alpine
RUN apk add --no-cache openssl
RUN mkdir -p /app/uploads && chown node:node /app/uploads
WORKDIR /app
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma/scientificTerms.ts ./prisma/scientificTerms.ts
EXPOSE 3000
USER node
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main"]
CMD ["node", "dist/main"]

View File

@ -1,88 +0,0 @@
export interface ScientificTerm {
term: string;
definition: string;
wikiId?: string;
}
export const SCIENTIFIC_TERMS: Record<string, ScientificTerm> = {
"گلیکوزآمینوگلیکان": {
term: "گلیکوزآمینوگلیکان (GAG)",
definition: "ترکیبات ساختاری ضروری برای بازسازی غضروف‌ها و مایع مفصلی که در Canhydrox GAG به وفور یافت می‌شود.",
wikiId: "gag"
},
"اسیدهای هومیک": {
term: "اسیدهای هومیک",
definition: "مواد آلی طبیعی موجود در عصاره پیت (Moor) که از مخاط روده محافظت کرده و سموم را جذب می‌کنند.",
wikiId: "humic-acids"
},
"کلاژن زیست‌فعال": {
term: "کلاژن زیست‌فعال",
definition: "پروتئین‌های اختصاصی هیدرولیز شده که به سرعت جذب استخوان و تاندون شده و استحکام ساختاری ایجاد می‌کنند.",
wikiId: "collagen"
},
"تیموکینون": {
term: "تیموکینون",
definition: "ماده موثره اصلی در روغن سیاه دانه (Nigella sativa) با خواص ضدالتهابی، ضدآلرژی و آنتی‌اکسیدان. با مهار مسیر NF-κB تولید سایتوکاین‌های پیش‌التهابی را کاهش می‌دهد.",
wikiId: "thymoquinone"
},
"امگا ۳": {
term: "امگا ۳",
definition: "اسیدهای چرب ضروری شامل EPA و DHA که برای سلامت پوست، مو، قلب و عملکرد شناختی حیاتی هستند. EPA پیش‌ماده رزولوین‌های ضدالتهابی و DHA ۳۰٪ وزن خشک مغز را تشکیل می‌دهد.",
wikiId: "omega3"
},
"میکروسیلور": {
term: "نقره میکروسیلور (Microsilver BG)",
definition: "ذرات نقره خالص با ساختار اسفنجی که بدون جذب در خون، اثر آنتی‌باکتریال پایداری بر سطح پوست و لثه دارند.",
wikiId: "silver"
},
"صدف لب‌سبز": {
term: "صدف لب‌سبز (Green-lipped Mussel)",
definition: "صدف بومی نیوزلند حاوی گلوکوزامین‌گلیکان‌های طبیعی که نقش حیاتی در بازسازی غضروف و مفاصل دارند.",
wikiId: "joints"
},
"نسبت Ca:P": {
term: "نسبت کلسیم به فسفر (Ca:P Ratio)",
definition: "نسبت ایده‌آل کلسیم به فسفر در رژیم حیوانات ۱.۲:۱ تا ۱.۵:۱ است. عدم تعادل این نسبت منجر به مشکلات کلیوی و استخوانی می‌شود. پودر پوسته تخم مرغ کانینا با ۳۶٪ کلسیم و ۰.۱٪ فسفر بهترین تنظیم‌کننده این نسبت است.",
wikiId: "nutrition"
},
"ویتامین D3": {
term: "ویتامین D3 (کوله‌کلسیفرول)",
definition: "فرم فعال ویتامین D که جذب کلسیم از روده را تا ۳۰٪ افزایش می‌دهد. هشدار: مصرف همزمان با ویتامین D2 (ارگوکلسیفرول) مجاز نیست زیرا رقابت این دو جذب مؤثر را کاهش می‌دهد.",
wikiId: "vitamins"
},
"BARF": {
term: "رژیم BARF (Biologically Appropriate Raw Food)",
definition: "رژیم غذایی خام متناسب با زیست‌شناسی حیوان که شامل گوشت خام، استخوان، اندام‌ها و سبزیجات است. مکمل‌های کانینا مانند Eierschalenpulver، Rinderblut Pulver و Rinderfett Pulver برای تکمیل این رژیم طراحی شده‌اند.",
wikiId: "nutrition"
},
"آغوز": {
term: "آغوز (Colostrum)",
definition: "اولین شیر پستانداران حاوی IgG, IgA, IgM و لاکتوفرین که سد دفاعی روده را تقویت کرده و ایمنی غیرفعال منتقل می‌کند. محصول Immun-Booster کانینا حاوی ۲۰٪ پودر آغوز است.",
wikiId: "immune"
},
"ال-کارنیتین": {
term: "ال-کارنیتین",
definition: "ماده‌ای شبه‌آمینواسید که اسیدهای چرب زنجیره بلند را به داخل میتوکندری کاردیومیوسیت‌ها منتقل کرده و ATP تولید می‌کند. محصول Herz Vital کانینا حاوی ۳۰,۰۰۰mg/kg ال-کارنیتین است.",
wikiId: "heart"
},
"زالزالک": {
term: "زالزالک (Crataegus oxyacantha)",
definition: "گیاه دارویی با اثر مهارکننده ACE طبیعی که جریان خون کرونر را بهبود بخشیده و فشار بر قلب را کاهش می‌دهد.",
wikiId: "heart"
},
"بیوتین": {
term: "بیوتین (ویتامین H / B7)",
definition: "کوآنزیم کربوکسیلازهای مسئول سنتز اسیدهای چرب و گلوکونئوژنز. کمبود آن منجر به ریزش مو و شکنندگی پنجه می‌شود. قرص Biotin-Tabs کانینا با ۱,۰۰۰,۰۰۰ میکروگرم در کیلوگرم قوی‌ترین دوز موجود است.",
wikiId: "skin"
},
"هیدروکسی آپاتیت": {
term: "هیدروکسی آپاتیت",
definition: "کلسیم فسفات بلوری که ۹۰٪ بافت دندان و بخش عمده استخوان را تشکیل می‌دهد. با جذب مستقیم در توده استخوانی، استحکام اسکلت را تضمین می‌کند.",
wikiId: "joints"
},
"EPA/DHA": {
term: "EPA و DHA",
definition: "ایکوزاپنتائنوئیک اسید (EPA) پیش‌ماده رزولوین‌های ضدالتهابی و دوکوزاهگزانوئیک اسید (DHA) ۳۰٪ وزن خشک مغز را تشکیل می‌دهد. نسبت ۳:۲ EPA:DHA در روغن سالمون کانینا بهینه‌سازی شده است.",
wikiId: "omega3"
}
};

View File

@ -55,7 +55,7 @@ async function main() {
console.log('Seeding products from seed-products-data.json...');
// 2. Read JSON data
const dataPath = path.join(process.cwd() + "/prisma", 'seed-products-data.json');
const dataPath = path.join(__dirname, 'seed-products-data.json');
if (!fs.existsSync(dataPath)) {
console.error('seed-products-data.json not found at', dataPath);
process.exit(1);

View File

@ -165,7 +165,7 @@ async function main() {
// 2. Seed Scientific Terms
console.log('Seeding Scientific Terms...');
const termsFilePath = path.join(process.cwd() + "/prisma", '..', '..', 'frontend', 'application', 'lib', 'data', 'scientificTerms.ts');
const termsFilePath = path.join(__dirname, '..', '..', 'frontend', 'application', 'lib', 'data', 'scientificTerms.ts');
if (fs.existsSync(termsFilePath)) {
const termsContent = fs.readFileSync(termsFilePath, 'utf-8');
const termsJsCode = ts.transpile(termsContent, {

View File

@ -46,4 +46,3 @@ async function bootstrap() {
await app.listen(process.env.PORT ?? 4001);
}
bootstrap();

View File

@ -7,9 +7,7 @@ export class WikiService {
constructor(private prisma: PrismaService) {}
async findAll(filters: PaginationDto) {
const { search, page = 1, limit = 10, sortBy: rawSortBy = 'term', sortOrder = 'asc' } = filters;
const allowedSortFields = ['key', 'term', 'wikiId'];
const sortBy = allowedSortFields.includes(rawSortBy) ? rawSortBy : 'term';
const { search, page = 1, limit = 10, sortBy = 'term', sortOrder = 'asc' } = filters;
const whereClause: any = {};
if (search) {

View File

@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite --port 5050 --host 127.0.0.1",
"build": "vite build",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},

View File

@ -186,7 +186,7 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{mediaList.map((media) => {
const imgUrl = media.url.startsWith('http') ? media.url : `https://apicanina.parsaaghayi.ir${media.url}`;
const imgUrl = media.url.startsWith('http') ? media.url : `http://localhost:4001${media.url}`;
const isSelected = selectedUrl && imgUrl === selectedUrl;
return (
<div

View File

@ -152,7 +152,7 @@ export default function Blogs() {
<tr key={blog.id} className="hover:bg-gray-50/50 transition-colors">
<td className="py-3 px-6">
{blog.imageUrl ? (
<img src={blog.imageUrl.startsWith('http') ? blog.imageUrl : `https://apicanina.parsaaghayi.ir${blog.imageUrl}`} alt="blog" className="w-16 h-10 rounded-lg object-cover border border-gray-200" />
<img src={blog.imageUrl.startsWith('http') ? blog.imageUrl : `http://localhost:4001${blog.imageUrl}`} alt="blog" className="w-16 h-10 rounded-lg object-cover border border-gray-200" />
) : (
<div className="w-16 h-10 rounded-lg bg-gray-100 flex items-center justify-center text-gray-400">
<ImageIcon className="w-4 h-4" />
@ -222,7 +222,7 @@ export default function Blogs() {
<label className="text-sm font-bold text-gray-700">تصویر شاخص</label>
<div className="flex items-center gap-4">
{formData.imageUrl ? (
<img src={formData.imageUrl.startsWith('http') ? formData.imageUrl : `https://apicanina.parsaaghayi.ir${formData.imageUrl}`} alt="preview" className="w-24 h-16 rounded-xl object-cover border border-gray-200" />
<img src={formData.imageUrl.startsWith('http') ? formData.imageUrl : `http://localhost:4001${formData.imageUrl}`} alt="preview" className="w-24 h-16 rounded-xl object-cover border border-gray-200" />
) : (
<div className="w-24 h-16 rounded-xl bg-gray-100 flex items-center justify-center text-gray-400">
<ImageIcon className="w-6 h-6" />

View File

@ -158,7 +158,7 @@ export default function Categories() {
<tr key={cat.id} className="hover:bg-gray-50/50 transition-colors">
<td className="py-3 px-6">
{cat.imageUrl ? (
<img src={cat.imageUrl.startsWith('http') ? cat.imageUrl : `https://apicanina.parsaaghayi.ir${cat.imageUrl}`} alt={cat.name} className="w-12 h-12 rounded-xl object-cover border border-gray-200" />
<img src={cat.imageUrl.startsWith('http') ? cat.imageUrl : `http://localhost:4001${cat.imageUrl}`} alt={cat.name} className="w-12 h-12 rounded-xl object-cover border border-gray-200" />
) : (
<div className="w-12 h-12 rounded-xl bg-gray-100 flex items-center justify-center text-gray-400">
<ImageIcon className="w-5 h-5" />
@ -240,7 +240,7 @@ export default function Categories() {
<label className="text-sm font-bold text-gray-700">تصویر دسته‌بندی</label>
<div className="flex items-center gap-4">
{formData.imageUrl ? (
<img src={formData.imageUrl.startsWith('http') ? formData.imageUrl : `https://apicanina.parsaaghayi.ir${formData.imageUrl}`} alt="preview" className="w-16 h-16 rounded-xl object-cover border border-gray-200" />
<img src={formData.imageUrl.startsWith('http') ? formData.imageUrl : `http://localhost:4001${formData.imageUrl}`} alt="preview" className="w-16 h-16 rounded-xl object-cover border border-gray-200" />
) : (
<div className="w-16 h-16 rounded-xl bg-gray-100 flex items-center justify-center text-gray-400">
<ImageIcon className="w-6 h-6" />

View File

@ -102,7 +102,7 @@ export default function MediaManager() {
};
const handleCopyUrl = async (media: Media) => {
const fullUrl = media.url.startsWith('http') ? media.url : `https://apicanina.parsaaghayi.ir${media.url}`;
const fullUrl = media.url.startsWith('http') ? media.url : `http://localhost:4001${media.url}`;
try {
await navigator.clipboard.writeText(fullUrl);
setCopiedId(media.id);
@ -202,7 +202,7 @@ export default function MediaManager() {
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 p-6">
{filtered.map(media => {
const imgUrl = media.url.startsWith('http') ? media.url : `https://apicanina.parsaaghayi.ir${media.url}`;
const imgUrl = media.url.startsWith('http') ? media.url : `http://localhost:4001${media.url}`;
return (
<div
key={media.id}

View File

@ -91,7 +91,7 @@ export default function Pets() {
<tr key={pet.id} className="hover:bg-gray-50/50 transition-colors">
<td className="py-3 px-6">
{pet.imageUrl ? (
<img src={pet.imageUrl.startsWith('http') ? pet.imageUrl : `https://apicanina.parsaaghayi.ir${pet.imageUrl}`} alt={pet.name} className="w-12 h-12 rounded-full object-cover border-2 border-purple-100" />
<img src={pet.imageUrl.startsWith('http') ? pet.imageUrl : `http://localhost:4001${pet.imageUrl}`} alt={pet.name} className="w-12 h-12 rounded-full object-cover border-2 border-purple-100" />
) : (
<div className="w-12 h-12 rounded-full bg-purple-50 flex items-center justify-center text-purple-300 border-2 border-purple-100">
<ImageIcon className="w-5 h-5" />

View File

@ -214,7 +214,7 @@ export default function Products() {
<tr key={product.id} className="hover:bg-gray-50/50 transition-colors">
<td className="py-3 px-6">
{product.imageUrl ? (
<img src={product.imageUrl.startsWith('http') ? product.imageUrl : `https://apicanina.parsaaghayi.ir${product.imageUrl}`} alt={product.name} className="w-12 h-12 rounded-xl object-cover border border-gray-200" />
<img src={product.imageUrl.startsWith('http') ? product.imageUrl : `http://localhost:4001${product.imageUrl}`} alt={product.name} className="w-12 h-12 rounded-xl object-cover border border-gray-200" />
) : (
<div className="w-12 h-12 rounded-xl bg-gray-100 flex items-center justify-center text-gray-400">
<ImageIcon className="w-5 h-5" />
@ -385,7 +385,7 @@ export default function Products() {
{formData.imageUrl ? (
<div className="w-full flex flex-col items-center">
<div className="relative group">
<img src={formData.imageUrl.startsWith('http') ? formData.imageUrl : `https://apicanina.parsaaghayi.ir${formData.imageUrl}`} alt="product" className="max-w-xs h-48 object-cover rounded-xl shadow-md" />
<img src={formData.imageUrl.startsWith('http') ? formData.imageUrl : `http://localhost:4001${formData.imageUrl}`} alt="product" className="max-w-xs h-48 object-cover rounded-xl shadow-md" />
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded-xl flex items-center justify-center gap-3">
<button type="button" onClick={() => setIsMediaSelectorOpen(true)} className="bg-white text-gray-800 px-4 py-2 rounded-lg font-bold text-sm">تغییر عکس</button>
</div>
@ -435,7 +435,7 @@ export default function Products() {
isOpen={isMediaSelectorOpen}
onClose={() => setIsMediaSelectorOpen(false)}
onSelect={(url) => setFormData({...formData, imageUrl: url})}
selectedUrl={formData.imageUrl.startsWith('http') ? formData.imageUrl : formData.imageUrl ? `https://apicanina.parsaaghayi.ir${formData.imageUrl}` : undefined}
selectedUrl={formData.imageUrl.startsWith('http') ? formData.imageUrl : formData.imageUrl ? `http://localhost:4001${formData.imageUrl}` : undefined}
/>
</div>
);

View File

@ -1,7 +1,7 @@
import axios from 'axios';
const api = axios.create({
baseURL: 'https://apicanina.parsaaghayi.ir/api', // Backend NestJS API URL
baseURL: 'http://localhost:4001/api', // Backend NestJS API URL
headers: {
'Content-Type': 'application/json',
},

View File

@ -3,11 +3,9 @@ import { ChevronRight, Calendar, User, Sparkles } from "lucide-react";
import Link from 'next/link';
import type { Metadata } from 'next';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'https://apicanina.parsaaghayi.ir';
async function getBlog(slug: string) {
try {
const res = await fetch(`${API_URL}/api/blogs/${slug}`, { next: { revalidate: 60 } });
const res = await fetch(`http://localhost:4001/api/blogs/${slug}`, { next: { revalidate: 60 } });
if (!res.ok) throw new Error('Failed to fetch blog');
const b = await res.json();
return {
@ -56,6 +54,7 @@ export default async function BlogPostPage({ params }: { params: Promise<{ slug:
return (
<div className="min-h-screen bg-medical-gray-50 pt-8 pb-20 px-4 font-vazir" dir="rtl">
<div className="max-w-7xl mx-auto">
{/* Breadcrumbs */}
<div className="flex items-center gap-2 text-[10px] font-black text-medical-gray-400 uppercase tracking-widest mb-10">
<Link href="/" className="hover:text-canina-blue transition-colors">خانه</Link>
<ChevronRight className="w-2.5 h-2.5" />

View File

@ -7,7 +7,7 @@ export const metadata: Metadata = {
};
async function getBlogs() {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'https://apicanina.parsaaghayi.ir';
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4001';
try {
const res = await fetch(`${apiUrl}/api/blogs`, { next: { revalidate: 60 } });
if (!res.ok) throw new Error('Failed to fetch blogs');

View File

@ -2,12 +2,10 @@ import Link from 'next/link';
import { ChevronRight } from 'lucide-react';
import type { Metadata } from 'next';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'https://apicanina.parsaaghayi.ir';
async function getWikiTerm(key: string) {
async function getBlog(slug: string) {
try {
const res = await fetch(`${API_URL}/api/wiki/${encodeURIComponent(key)}`, { next: { revalidate: 60 } });
if (!res.ok) throw new Error('Failed to fetch wiki term');
const res = await fetch(`http://localhost:4001/api/blogs/${slug}`, { next: { revalidate: 60 } });
if (!res.ok) throw new Error('Failed to fetch blog');
return res.json();
} catch (error) {
console.error(error);
@ -17,23 +15,23 @@ async function getWikiTerm(key: string) {
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
const { slug } = await params;
const term = await getWikiTerm(slug);
if (!term) return { title: 'ترکیب علمی یافت نشد' };
const blog = await getBlog(slug);
if (!blog) return { title: 'مقاله یافت نشد' };
return {
title: term.metaTitle || term.term,
description: term.metaDescription || term.definition?.substring(0, 160),
title: blog.metaTitle || blog.title,
description: blog.metaDescription || blog.title,
};
}
export default async function WikiTermPage({ params }: { params: Promise<{ slug: string }> }) {
export default async function BlogPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const term = await getWikiTerm(slug);
const blog = await getBlog(slug);
if (!term) {
if (!blog) {
return (
<div className="min-h-[50vh] flex flex-col items-center justify-center font-vazir text-center" dir="rtl">
<h1 className="text-3xl font-black text-medical-gray-900 mb-4">ترکیب علمی یافت نشد</h1>
<h1 className="text-3xl font-black text-medical-gray-900 mb-4">مقاله‌ای یافت نشد</h1>
<Link href="/wiki" className="text-canina-blue font-bold flex items-center gap-2">
<ChevronRight className="w-4 h-4" />
بازگشت به دانشنامه
@ -47,16 +45,32 @@ export default async function WikiTermPage({ params }: { params: Promise<{ slug:
<div className="max-w-4xl mx-auto">
<Link href="/wiki" className="inline-flex items-center gap-2 text-medical-gray-400 hover:text-canina-blue transition-colors font-bold mb-8">
<ChevronRight className="w-5 h-5" />
بازگشت به دانشنامه
بازگشت به مقالات
</Link>
{blog.imageUrl && (
<div className="aspect-video rounded-[2rem] overflow-hidden mb-12 shadow-xl border border-medical-gray-100">
<img src={blog.imageUrl} alt={blog.title} className="w-full h-full object-cover" />
</div>
)}
<h1 className="text-4xl lg:text-5xl font-black text-medical-gray-900 mb-6 leading-tight">
{term.term}
{blog.title}
</h1>
<div className="flex items-center gap-4 text-sm font-bold text-medical-gray-400 mb-12 pb-8 border-b border-medical-gray-100">
<span>نوشته شده در {new Date(blog.createdAt).toLocaleDateString('fa-IR')}</span>
{blog.author && (
<>
<span className="w-1 h-1 rounded-full bg-medical-gray-300" />
<span>توسط {blog.author.firstName} {blog.author.lastName}</span>
</>
)}
</div>
<div
className="prose prose-lg prose-medical-gray max-w-none prose-headings:font-black prose-p:leading-relaxed prose-a:text-canina-blue"
dangerouslySetInnerHTML={{ __html: term.definition }}
dangerouslySetInnerHTML={{ __html: blog.content }}
/>
</div>
</div>

View File

@ -65,8 +65,11 @@ import { useRouter } from 'next/navigation';
export default function Hero({ banners = [] }: { banners?: any[] }) {
const router = useRouter();
const getText = useSettingsStore(state => state.getText);
const title = getText('hero_title', '') || (banners.length > 0 ? banners[0].title : "تخصص آلمانی در خدمت\nسلامت پت‌های خانگی");
const subtitle = getText('hero_desc', '') || (banners.length > 0 ? banners[0].subtitle : 'بیش از <span className="font-bold text-canina-blue">۴۰ سال</span> تجربه نوآورانه در تولید مکمل‌های درمانی با بالاترین استاندارد کیفی «گرید دارویی اختصاصی». راهکار هوشمند برای هر نیاز بالینی.');
const title = banners.length > 0 ? banners[0].title : getText('hero_title', "تخصص آلمانی در خدمت\nسلامت پت‌های خانگی");
const subtitle = banners.length > 0 ? banners[0].subtitle : getText(
'hero_desc',
'بیش از <span className="font-bold text-canina-blue">۴۰ سال</span> تجربه نوآورانه در تولید مکمل‌های درمانی با بالاترین استاندارد کیفی «گرید دارویی اختصاصی». راهکار هوشمند برای هر نیاز بالینی.'
);
const titleParts = title.split('\n');
return (

View File

@ -11,7 +11,6 @@ import { useRouter } from 'next/navigation';
export default function IngredientWiki() {
const router = useRouter();
const texts = useSettingsStore(state => state.texts);
const scientificTerms = useSettingsStore(state => state.scientificTerms);
const [ingredientsWiki, setIngredientsWiki] = useState<IngredientInfo[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [searchQuery, setSearchQuery] = useState("");
@ -32,31 +31,12 @@ export default function IngredientWiki() {
description: item.description,
benefits: Array.isArray(item.benefits) ? item.benefits : []
})));
return;
}
} catch (e) {
console.error("Failed to parse ingredients_wiki:", e);
}
}
const terms = Object.values(scientificTerms);
if (terms.length > 0) {
setIngredientsWiki(terms.map((t: any) => {
let benefits: string[] = [];
if (t.keywords) {
try {
const parsed = typeof t.keywords === 'string' ? JSON.parse(t.keywords) : t.keywords;
if (Array.isArray(parsed)) benefits = parsed;
} catch (e) {}
}
return {
id: t.key,
name: t.term,
description: t.definition,
benefits
};
}));
}
}, [texts, scientificTerms]);
}, [texts]);
return (
<div className="bg-white py-12 px-4 overflow-hidden font-vazir" dir="rtl">

View File

@ -1,6 +1,6 @@
import axios from 'axios';
const baseURL = typeof window !== 'undefined' ? '/api' : (process.env.NEXT_PUBLIC_API_URL || 'https://apicanina.parsaaghayi.ir/api');
const baseURL = typeof window !== 'undefined' ? '/api' : (process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4001/api');
const api = axios.create({
baseURL,

View File

@ -56,7 +56,7 @@ export class ProductService {
onSetOfAction: data.onSetOfAction,
optimisticTemplate: data.optimisticTemplate,
feedingAdvice: data.feedingAdvice,
image: data.imageUrl?.startsWith('http') ? data.imageUrl : data.imageUrl ? `/api${data.imageUrl}` : data.image,
image: data.imageUrl?.startsWith('http') ? data.imageUrl : data.imageUrl ? `http://localhost:4001${data.imageUrl}` : data.image,
main_ingredients: data.ingredientList?.map((i: any) => i.ingredient) || data.ingredients?.split(/[،,-]/).map((s: string) => s.trim()).filter(Boolean) || [],
symptoms: data.symptoms?.map((s: any) => s.symptom || s) || [],

View File

@ -1,15 +1,17 @@
server {
listen 8080;
server_name canina.parsaaghayi.ir;
listen 8080 default_server;
server_name _;
root /app;
index index.html;
# Security Headers
server_tokens off;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Frame-ों "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline' 'unsafe-eval' https://images.unsplash.com https://canina.de;" always;
# Main Application (Next.js)
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
@ -18,22 +20,11 @@ server {
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
# Backend API
location /api {
proxy_pass http://backend_prod:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
server {
listen 8080;
server_name admincanina.parsaaghayi.ir;
root /app/admin_static;
index index.html;
@ -43,15 +34,14 @@ server {
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline' 'unsafe-eval' https://images.unsplash.com https://canina.de;" always;
# SPA routing for Admin Panel
location / {
try_files $uri $uri/ /index.html;
}
# Backend API for Admin
location /api {
proxy_pass http://backend_prod:3000;
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';

View File

@ -1,3 +0,0 @@
#!/bin/sh
nginx
npm start