Compare commits
67
Commits
68863eb44e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
353d4639b4 | ||
|
|
dfbb61f75f | ||
|
|
c1d7f61a45 | ||
|
|
c200159afd | ||
|
|
ebc97e03a4 | ||
|
|
b088f68fd7 | ||
|
|
1ceb7cb156 | ||
|
|
e571b19f4a | ||
|
|
bcd2b756ed | ||
|
|
e8010b1e79 | ||
|
|
7c2eeda232 | ||
|
|
7e4605d2b7 | ||
|
|
7d78a1fc58 | ||
|
|
324298c9d7 | ||
|
|
2d1c670715 | ||
|
|
450d5dd239 | ||
|
|
f0f483a9d1 | ||
|
|
00abef23df | ||
|
|
29dc8459f7 | ||
|
|
afcace4fa3 | ||
|
|
2ca30178ed | ||
|
|
40ec3d406b | ||
|
|
b801867611 | ||
|
|
3a72752598 | ||
|
|
b2af06397a | ||
|
|
838cc01190 | ||
|
|
adedb9ea41 | ||
|
|
e5fc0e1705 | ||
|
|
e2f6853c2c | ||
|
|
9bf2a30f8d | ||
|
|
ad151bbae7 | ||
|
|
8865149aab | ||
|
|
879481df9f | ||
|
|
2c58978c78 | ||
|
|
3c17bd5081 | ||
|
|
adcb288064 | ||
|
|
016b4074d8 | ||
|
|
5e2d5dfabb | ||
|
|
9759ab2698 | ||
|
|
82e33fb0e0 | ||
|
|
9dab4f929c | ||
|
|
bef132f82f | ||
|
|
07570bd14a | ||
|
|
d68db09810 | ||
|
|
573d4ab452 | ||
|
|
436acf3cca | ||
|
|
2c4fd5a9d4 | ||
|
|
cf6e71839b | ||
|
|
99ecb0fa9f | ||
|
|
bd310e8d18 | ||
|
|
27a9a82a37 | ||
|
|
00e7490912 | ||
|
|
23e0c97822 | ||
|
|
2556bbfa1b | ||
|
|
f05cd8b820 | ||
|
|
d16db803ba | ||
|
|
1b491357ab | ||
|
|
61a2e95278 | ||
|
|
426dc783df | ||
|
|
575e4868be | ||
|
|
41ead01e02 | ||
|
|
4e2ecaa69e | ||
|
|
632edaf081 | ||
|
|
d56dff2a2f | ||
|
|
696cec2fb5 | ||
|
|
729b19bc12 | ||
|
|
6f1f42ba7a |
@@ -0,0 +1,251 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { BotIcon, SendIcon, SparklesIcon, XIcon } from 'lucide-react';
|
||||||
|
import { track } from '../analytics';
|
||||||
|
|
||||||
|
type ChatMessage = {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
streaming?: boolean;
|
||||||
|
error?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SseFrame = {
|
||||||
|
content?: string;
|
||||||
|
error?: string;
|
||||||
|
done?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** شناسه گفتگو برای هر نشست — در sessionStorage میماند تا رفرش صفحه تاریخچه را نگه دارد */
|
||||||
|
function getThreadId(): string {
|
||||||
|
const KEY = 'chat_thread_id';
|
||||||
|
let id = sessionStorage.getItem(KEY);
|
||||||
|
if (!id) {
|
||||||
|
id = `t_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||||
|
sessionStorage.setItem(KEY, id);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** خواندن فریمهای SSE از پاسخ fetch (استریم توکنبهتوکن) */
|
||||||
|
async function readSse(response: Response, onFrame: (frame: SseFrame) => void) {
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) throw new Error('پاسخ استریمی ندارد');
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() ?? '';
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed.startsWith('data: ')) {
|
||||||
|
try {
|
||||||
|
onFrame(JSON.parse(trimmed.slice(6)) as SseFrame);
|
||||||
|
} catch {
|
||||||
|
// فریم ناقص — نادیده بگیر
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatWidget() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||||
|
const scrollEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const assistantIndexRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// پیشنهاد پرسشها فقط یک بار برای هر بازشدن اول
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || suggestions.length > 0) return;
|
||||||
|
fetch('/chat/suggestions')
|
||||||
|
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
|
||||||
|
.then((d) => setSuggestions((d.questions ?? []).slice(0, 4)))
|
||||||
|
.catch(() => setSuggestions([]));
|
||||||
|
}, [open, suggestions.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||||
|
}, [messages, open]);
|
||||||
|
|
||||||
|
const sendMessage = async (text: string) => {
|
||||||
|
const question = text.trim();
|
||||||
|
if (!question || isLoading) return;
|
||||||
|
track('chat-question')
|
||||||
|
setInput('');
|
||||||
|
setIsLoading(true);
|
||||||
|
setMessages((prev) => {
|
||||||
|
const next: ChatMessage[] = [
|
||||||
|
...prev,
|
||||||
|
{ role: 'user', content: question },
|
||||||
|
{ role: 'assistant', content: '', streaming: true },
|
||||||
|
];
|
||||||
|
assistantIndexRef.current = next.length - 1;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const patchAssistant = (patch: Partial<ChatMessage>) => {
|
||||||
|
setMessages((prev) => {
|
||||||
|
const idx = assistantIndexRef.current;
|
||||||
|
if (idx === null || !prev[idx]) return prev;
|
||||||
|
const updated = [...prev];
|
||||||
|
updated[idx] = { ...updated[idx], ...patch };
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ message: question, thread_id: getThreadId() });
|
||||||
|
const response = await fetch(`/chat/stream?${params.toString()}`, {
|
||||||
|
headers: { Accept: 'text/event-stream' },
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
|
||||||
|
let accumulated = '';
|
||||||
|
await readSse(response, (frame) => {
|
||||||
|
if (frame.error) {
|
||||||
|
patchAssistant({ content: frame.error, streaming: false, error: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (frame.content) {
|
||||||
|
accumulated += frame.content;
|
||||||
|
patchAssistant({ content: accumulated });
|
||||||
|
}
|
||||||
|
if (frame.done) patchAssistant({ content: accumulated || 'پاسخی دریافت نشد.', streaming: false });
|
||||||
|
});
|
||||||
|
patchAssistant({ streaming: false });
|
||||||
|
} catch (err) {
|
||||||
|
patchAssistant({
|
||||||
|
content: 'سرویس گفتگو در دسترس نیست؛ لطفاً کمی بعد دوباره تلاش کنید.',
|
||||||
|
streaming: false,
|
||||||
|
error: true,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* دکمه شناور — گوشه راستپایین (دکمه تماس سمت چپ است) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen((v) => {
|
||||||
|
if (!v) track('chat-open');
|
||||||
|
return !v;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
aria-label={open ? 'بستن گفتگو' : 'گفتگو با دستیار هوشمند'}
|
||||||
|
className="group fixed bottom-8 right-8 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-ivory text-ink shadow-[0_10px_30px_rgba(0,0,0,0.45)] transition-transform duration-200 ease-lux hover:scale-105">
|
||||||
|
<span className="absolute inset-0 rounded-full border border-ivory/60 opacity-0 transition-transform duration-300 ease-lux group-hover:scale-125 group-hover:opacity-100" />
|
||||||
|
{open ? <XIcon className="h-5 w-5" /> : <BotIcon className="h-5 w-5" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* پنل گفتگو */}
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className="fixed bottom-24 right-4 z-40 flex h-[min(560px,72vh)] w-[min(380px,calc(100vw-2rem))] flex-col overflow-hidden rounded-2xl border border-copper/30 bg-char shadow-lux sm:right-8">
|
||||||
|
{/* سربرگ */}
|
||||||
|
<div className="flex items-center gap-3 border-b border-ivory/10 bg-ink px-4 py-3">
|
||||||
|
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-copper/15 text-copper">
|
||||||
|
<SparklesIcon className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-bold text-ivory">دستیار هوشمند آرامش</p>
|
||||||
|
<p className="text-[11px] font-light text-ivory/50">پاسخ از محتوای همین سایت</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
aria-label="بستن"
|
||||||
|
className="rounded-full p-1.5 text-ivory/50 transition-colors hover:bg-ivory/10 hover:text-ivory">
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* پیامها */}
|
||||||
|
<div className="flex-1 space-y-3 overflow-y-auto px-4 py-4">
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<BotIcon className="mx-auto h-8 w-8 text-copper/60" />
|
||||||
|
<p className="mt-3 text-sm font-light leading-7 text-ivory/60">
|
||||||
|
سلام! هر سؤالی درباره خدمات، پروژهها و مقالات
|
||||||
|
<span className="font-bold text-ivory"> گروه ساختمانی آرامش </span>
|
||||||
|
دارید بپرسید.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{messages.map((msg, i) => (
|
||||||
|
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||||
|
<div
|
||||||
|
className={`max-w-[85%] whitespace-pre-wrap rounded-2xl px-3.5 py-2.5 text-[13px] leading-6 ${
|
||||||
|
msg.role === 'user'
|
||||||
|
? 'rounded-bl-md bg-copper text-char'
|
||||||
|
: msg.error
|
||||||
|
? 'rounded-br-md border border-red-400/30 bg-red-400/10 text-red-200'
|
||||||
|
: 'rounded-br-md border border-ivory/10 bg-ink text-ivory'
|
||||||
|
}`}>
|
||||||
|
{msg.content || (msg.streaming ? '…' : '')}
|
||||||
|
{msg.streaming && msg.content && (
|
||||||
|
<span className="ms-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-copper align-middle" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={scrollEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* پیشنهاد پرسشها */}
|
||||||
|
{suggestions.length > 0 && messages.length === 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 border-t border-ivory/10 px-3 py-2.5">
|
||||||
|
{suggestions.map((q) => (
|
||||||
|
<button
|
||||||
|
key={q}
|
||||||
|
type="button"
|
||||||
|
onClick={() => sendMessage(q)}
|
||||||
|
className="rounded-full border border-copper/30 px-2.5 py-1 text-[11px] text-copper transition-colors hover:bg-copper hover:text-char">
|
||||||
|
{q}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ورودی */}
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
sendMessage(input);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 border-t border-ivory/10 bg-ink px-3 py-3">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder="پرسش خود را بنویسید…"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1 rounded-full border border-ivory/15 bg-char px-4 py-2 text-[13px] text-ivory placeholder:text-ivory/30 focus:border-copper/60 focus:outline-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || !input.trim()}
|
||||||
|
aria-label="ارسال"
|
||||||
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-copper text-char transition-transform hover:scale-105 disabled:bg-ivory/15 disabled:text-ivory/40 disabled:hover:scale-100">
|
||||||
|
<SendIcon className="h-4 w-4 -scale-x-100" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
"""سرویس چتبات سایت آرامش — FastAPI با استریم SSE و بازیابی ترکیبی روی محتوای دیتابیس."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Query, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from langchain_core.messages import AIMessage, HumanMessage
|
||||||
|
|
||||||
|
from . import content as content_mod
|
||||||
|
from . import pipeline
|
||||||
|
from . import vectorstore
|
||||||
|
from .config import settings
|
||||||
|
from .indexing import INDEX, sync_index
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
|
||||||
|
)
|
||||||
|
log = logging.getLogger("chatbot")
|
||||||
|
|
||||||
|
|
||||||
|
# --- چرخه حیات: ساخت ایندکس در شروع + بازسازی خودکار وقتی محتوا عوض شود ---
|
||||||
|
|
||||||
|
_refresh_task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_app: FastAPI):
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(sync_index)
|
||||||
|
except Exception as e:
|
||||||
|
log.critical("Initial index sync failed: %s — chat will be unavailable until reindex", e)
|
||||||
|
global _refresh_task
|
||||||
|
_refresh_task = asyncio.create_task(_refresh_loop())
|
||||||
|
yield
|
||||||
|
_refresh_task.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
async def _refresh_loop() -> None:
|
||||||
|
interval = settings.index_refresh_seconds
|
||||||
|
if interval <= 0:
|
||||||
|
return
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
try:
|
||||||
|
# همگامسازی افزایشی است — فقط سطرهای جدید/تغییریافته امبد میشوند
|
||||||
|
await asyncio.to_thread(sync_index)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Background index refresh failed: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
api = FastAPI(
|
||||||
|
title="Aramesh Site Chatbot",
|
||||||
|
version="1.0.0",
|
||||||
|
description="دستیار گفتگوی محتوای سایت — بازیابی ترکیبی (pgvector + BM25) با ریرنکر BGE",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
api.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=False,
|
||||||
|
allow_methods=["GET", "POST"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- محدودسازی نرخ درخواست سبک در حافظه (پنجره لغزان per-IP) ---
|
||||||
|
|
||||||
|
_hits: dict[str, deque] = defaultdict(deque)
|
||||||
|
_last_sweep = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_limited(ip: str) -> bool:
|
||||||
|
global _last_sweep
|
||||||
|
now = time.time()
|
||||||
|
# پاکسازی دورهای کلیدهای بیمصرف تا دیکشنری بینهایت بزرگ نشود
|
||||||
|
if now - _last_sweep > settings.rate_limit_window:
|
||||||
|
stale = [k for k, q in _hits.items() if not q or now - q[-1] > settings.rate_limit_window]
|
||||||
|
for k in stale:
|
||||||
|
_hits.pop(k, None)
|
||||||
|
_last_sweep = now
|
||||||
|
window = _hits[ip]
|
||||||
|
while window and now - window[0] > settings.rate_limit_window:
|
||||||
|
window.popleft()
|
||||||
|
if len(window) >= settings.rate_limit_messages:
|
||||||
|
return True
|
||||||
|
window.append(now)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _client_ip(request: Request | None) -> str:
|
||||||
|
if request is None:
|
||||||
|
return "?"
|
||||||
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
|
return forwarded.split(",")[0].strip() if forwarded else (request.client.host if request.client else "?")
|
||||||
|
|
||||||
|
|
||||||
|
# --- کمکیهای SSE ---
|
||||||
|
|
||||||
|
def _sse(payload: dict) -> str:
|
||||||
|
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _chunk_text(chunk) -> str:
|
||||||
|
"""استخراج متن از قطعه استریم LLM (رشته یا لیست بخشها)."""
|
||||||
|
content = getattr(chunk, "content", "")
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content
|
||||||
|
if isinstance(content, list):
|
||||||
|
return "".join(p.get("text", "") for p in content if isinstance(p, dict))
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
async def _stream_answer(message: str, thread_id: str):
|
||||||
|
if not INDEX.ready:
|
||||||
|
yield _sse({"error": "ایندکس محتوا هنوز آماده نیست؛ چند لحظه بعد دوباره تلاش کنید.", "done": True})
|
||||||
|
return
|
||||||
|
if pipeline.get_llm() is None:
|
||||||
|
yield _sse({"error": pipeline.LLM_UNAVAILABLE, "done": True})
|
||||||
|
return
|
||||||
|
|
||||||
|
config = {"configurable": {"thread_id": thread_id}}
|
||||||
|
inputs = {"messages": [HumanMessage(content=message)]}
|
||||||
|
try:
|
||||||
|
async for event in pipeline.graph.astream_events(inputs, config=config, version="v2"):
|
||||||
|
# فقط توکنهای پاسخ نهایی — نه فراخوانیهای کمکی (بازنویسی/گسترش پرسش)
|
||||||
|
if event["event"] == "on_chat_model_stream" and "generate" in (event.get("tags") or []):
|
||||||
|
text = _chunk_text(event["data"]["chunk"])
|
||||||
|
if text:
|
||||||
|
yield _sse({"content": text, "done": False})
|
||||||
|
yield _sse({"content": "", "done": True})
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("Streaming failed")
|
||||||
|
yield _sse({"error": f"خطا در تولید پاسخ: {e}", "done": True})
|
||||||
|
|
||||||
|
|
||||||
|
# --- مسیرها ---
|
||||||
|
|
||||||
|
@api.get("/chat/healthz")
|
||||||
|
async def healthz():
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"index_ready": INDEX.ready,
|
||||||
|
"documents": len(INDEX.docs),
|
||||||
|
"vectors": await asyncio.to_thread(vectorstore.vector_count),
|
||||||
|
"llm_configured": bool(settings.llm_base_url),
|
||||||
|
"reranker_enabled": settings.reranker_enabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/chat/suggestions")
|
||||||
|
async def suggestions():
|
||||||
|
return {"questions": _get_suggestions()}
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/chat/eval")
|
||||||
|
async def eval_question(message: str = Query(...)):
|
||||||
|
"""مخصوص ارزیابی RAGAS — پاسخ + اسناد بازیابیشده، با thread تازه."""
|
||||||
|
if not INDEX.ready:
|
||||||
|
return {"error": "index not ready", "question": message, "answer": "", "contexts": []}
|
||||||
|
if pipeline.get_llm() is None:
|
||||||
|
return {"error": "llm not configured", "question": message, "answer": "", "contexts": []}
|
||||||
|
|
||||||
|
thread_id = f"eval_{uuid.uuid4().hex[:10]}"
|
||||||
|
config = {"configurable": {"thread_id": thread_id}}
|
||||||
|
inputs = {"messages": [HumanMessage(content=message)]}
|
||||||
|
final_state = await pipeline.graph.ainvoke(inputs, config=config)
|
||||||
|
|
||||||
|
answer = ""
|
||||||
|
for msg in reversed(final_state.get("messages", [])):
|
||||||
|
if isinstance(msg, AIMessage):
|
||||||
|
answer = msg.content if isinstance(msg.content, str) else str(msg.content)
|
||||||
|
break
|
||||||
|
return {
|
||||||
|
"question": message,
|
||||||
|
"contextualized_query": final_state.get("contextualized_query") or message,
|
||||||
|
"answer": answer,
|
||||||
|
"contexts": final_state.get("documents", []),
|
||||||
|
"thread_id": thread_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/chat/reindex")
|
||||||
|
async def reindex(request: Request):
|
||||||
|
"""همگامسازی اجباری (امبد دوباره همه سطرها) — با توکن محافظت میشود."""
|
||||||
|
if settings.reindex_token and request.headers.get("x-reindex-token") != settings.reindex_token:
|
||||||
|
raise HTTPException(status_code=403, detail="invalid reindex token")
|
||||||
|
stats = await asyncio.to_thread(sync_index, True)
|
||||||
|
return {"status": "ok", "documents": len(INDEX.docs), "stats": stats}
|
||||||
|
|
||||||
|
|
||||||
|
# سازگاری با eval_ragas.py قدیمی که آدرس را /chat/{username}/eval میسازد
|
||||||
|
|
||||||
|
@api.get("/chat/{scope}/stream")
|
||||||
|
async def stream_chat_scoped(scope: str, message: str = Query(...), thread_id: str = Query(default=""), request: Request = None):
|
||||||
|
return await stream_chat(message=message, thread_id=thread_id, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/chat/{scope}/eval")
|
||||||
|
async def eval_scoped(scope: str, message: str = Query(...)):
|
||||||
|
return await eval_question(message=message)
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/chat/{scope}/suggestions")
|
||||||
|
async def suggestions_scoped(scope: str):
|
||||||
|
return {"questions": _get_suggestions()}
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/chat/stream")
|
||||||
|
async def stream_chat(message: str = Query(...), thread_id: str = Query(default=""), request: Request = None):
|
||||||
|
"""استریم توکنبهتوکن پاسخ با SSE."""
|
||||||
|
if _rate_limited(_client_ip(request)):
|
||||||
|
raise HTTPException(status_code=429, detail="تعداد پیامهای شما زیاد است؛ کمی بعد دوباره تلاش کنید.")
|
||||||
|
|
||||||
|
thread_id = thread_id or f"t_{uuid.uuid4().hex[:10]}"
|
||||||
|
|
||||||
|
async def generate():
|
||||||
|
async for chunk in _stream_answer(message, thread_id):
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
generate(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- پیشنهاد پرسشها (کششده) ---
|
||||||
|
|
||||||
|
_suggestions_cache: tuple[float, list[str]] = (0.0, [])
|
||||||
|
|
||||||
|
|
||||||
|
def _get_suggestions() -> list[str]:
|
||||||
|
global _suggestions_cache
|
||||||
|
now = time.time()
|
||||||
|
if _suggestions_cache[1] and now - _suggestions_cache[0] < settings.suggestions_ttl:
|
||||||
|
return _suggestions_cache[1]
|
||||||
|
|
||||||
|
questions: list[str] = []
|
||||||
|
if INDEX.ready:
|
||||||
|
faqs = [d for d in INDEX.docs if d["type"] == "faq"][:8]
|
||||||
|
titles = [d["title"] for d in INDEX.docs if d["type"] == "article"][:10]
|
||||||
|
model = pipeline.get_llm()
|
||||||
|
if model and (faqs or titles):
|
||||||
|
material = "\n".join([d["text"][:200] for d in faqs] + titles)
|
||||||
|
try:
|
||||||
|
prompt = (
|
||||||
|
"بر اساس محتوای سایت «گروه ساختمانی آرامش» ۴ پرسش کوتاه و جذاب بنویس که بازدیدکننده "
|
||||||
|
"احتمالاً میپرسد. هر پرسش حداکثر ۱۰ کلمه و به فارسی.\n"
|
||||||
|
'فقط آرایه JSON رشتهای برگردان: ["پرسش ۱", "پرسش ۲", ...]\n\n'
|
||||||
|
f"محتوا:\n{material[:2000]}"
|
||||||
|
)
|
||||||
|
resp = model.invoke([HumanMessage(content=prompt)])
|
||||||
|
text = resp.content if isinstance(resp.content, str) else ""
|
||||||
|
if text.startswith("```"):
|
||||||
|
text = text.split("```")[1]
|
||||||
|
text = text[4:] if text.startswith("json") else text
|
||||||
|
import re
|
||||||
|
match = re.search(r"\[.*\]", text, re.DOTALL)
|
||||||
|
if match:
|
||||||
|
questions = [q.strip() for q in json.loads(match.group()) if isinstance(q, str) and q.strip()][:4]
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Suggestion generation failed: %s", e)
|
||||||
|
if not questions and faqs:
|
||||||
|
# جایگزین: خود پرسشهای متداول
|
||||||
|
questions = [d["text"].split("\n")[0].removeprefix("پرسش: ") for d in faqs[:4]]
|
||||||
|
|
||||||
|
_suggestions_cache = (now, questions)
|
||||||
|
return questions
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run(api, host="0.0.0.0", port=8000)
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
"""خواندن محتوای سایت از PostgreSQL و تبدیل آن به سندهای قابل ایندکس.
|
||||||
|
|
||||||
|
هر سند یک dict با کلیدهای text / type / title / url است. بدنه مقالهها و
|
||||||
|
پروژهها هم میتواند آرایه پاراگراف باشد و هم سند TipTap (JSON) — هر دو
|
||||||
|
پشتیبانی میشوند. مقالههای پیشنویس ایندکس نمیشوند.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .persian import normalize
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# URL پایه صفحات — چتبات مسیر نسبی میدهد؛ فرانتاند روی همان دامنه است
|
||||||
|
_ARTICLE_URL = "/blog/{slug}"
|
||||||
|
_PROJECT_URL = "/projects/{slug}"
|
||||||
|
|
||||||
|
|
||||||
|
def _psycopg_dsn(database_url: str) -> str:
|
||||||
|
"""DATABASE_URL فرمت SQLAlchemy (postgresql+psycopg://) را به DSN psycopg3 تبدیل میکند."""
|
||||||
|
return database_url.replace("+psycopg", "").replace("+asyncpg", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_rows(dsn: str, sql: str) -> list[dict[str, Any]]:
|
||||||
|
import psycopg
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
|
||||||
|
with psycopg.connect(dsn, row_factory=dict_row, connect_timeout=10) as conn:
|
||||||
|
cur = conn.execute(sql)
|
||||||
|
return cur.fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def tiptap_blocks(node: Any) -> list[str]:
|
||||||
|
"""متن بلوکبهبلوک از سند TipTap (یا هر JSON تو در تو) بیرون میکشد."""
|
||||||
|
if not isinstance(node, dict):
|
||||||
|
return []
|
||||||
|
ntype = node.get("type")
|
||||||
|
if ntype in ("paragraph", "heading", "codeBlock"):
|
||||||
|
text = _inline_text(node).strip()
|
||||||
|
return [text] if text else []
|
||||||
|
blocks: list[str] = []
|
||||||
|
for child in node.get("content") or []:
|
||||||
|
blocks.extend(tiptap_blocks(child))
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
def _inline_text(node: dict) -> str:
|
||||||
|
parts: list[str] = []
|
||||||
|
for child in node.get("content") or []:
|
||||||
|
ctype = child.get("type")
|
||||||
|
if ctype == "text":
|
||||||
|
parts.append(child.get("text", ""))
|
||||||
|
elif ctype == "hardBreak":
|
||||||
|
parts.append(" ")
|
||||||
|
elif ctype == "image":
|
||||||
|
alt = (child.get("attrs") or {}).get("alt") or ""
|
||||||
|
if alt:
|
||||||
|
parts.append(f"[تصویر: {alt}]")
|
||||||
|
else:
|
||||||
|
parts.append(_inline_text(child))
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def body_to_text(body: Any) -> str:
|
||||||
|
"""بدنه مقاله/پروژه (آرایه پاراگراف یا سند TipTap) → متن یکپارچه."""
|
||||||
|
if not body:
|
||||||
|
return ""
|
||||||
|
if isinstance(body, list):
|
||||||
|
# فرمت قدیمی: آرایه رشتهای پاراگرافها
|
||||||
|
return "\n".join(str(p).strip() for p in body if str(p).strip())
|
||||||
|
blocks = tiptap_blocks(body)
|
||||||
|
return "\n".join(b for b in blocks if b)
|
||||||
|
|
||||||
|
|
||||||
|
def _doc(text: str, dtype: str, title: str, url: str) -> dict | None:
|
||||||
|
text = (text or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
return {"text": text, "type": dtype, "title": title, "url": url}
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(value: Any) -> str:
|
||||||
|
return normalize(str(value or "")).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_site_documents(database_url: str) -> list[dict]:
|
||||||
|
"""همه محتوای عمومی سایت را به سند تبدیل میکند (بدون وابستگی به ORM بکاند)."""
|
||||||
|
dsn = _psycopg_dsn(database_url)
|
||||||
|
docs: list[dict] = []
|
||||||
|
|
||||||
|
# --- برند و اطلاعات تماس (رکورد یگانه) ---
|
||||||
|
brand_rows = _fetch_rows(dsn, "SELECT name, phone, email, address FROM brand LIMIT 1")
|
||||||
|
if brand_rows:
|
||||||
|
b = brand_rows[0]
|
||||||
|
contact = "\n".join(
|
||||||
|
filter(None, [
|
||||||
|
f"نام مجموعه: {_clean(b['name'])}",
|
||||||
|
f"تلفن تماس: {_clean(b['phone'])}",
|
||||||
|
f"ایمیل: {_clean(b['email'])}",
|
||||||
|
f"آدرس: {_clean(b['address'])}",
|
||||||
|
])
|
||||||
|
)
|
||||||
|
d = _doc(contact, "contact", "اطلاعات تماس", "/contact")
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
|
# --- دفاتر ---
|
||||||
|
for row in _fetch_rows(dsn, "SELECT city, address, hours FROM offices ORDER BY position"):
|
||||||
|
d = _doc(
|
||||||
|
f"دفتر {_clean(row['city'])} — آدرس: {_clean(row['address'])} — ساعت کاری: {_clean(row['hours'])}",
|
||||||
|
"office", f"دفتر {_clean(row['city'])}", "/contact",
|
||||||
|
)
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
|
# --- خدمات ---
|
||||||
|
for row in _fetch_rows(dsn, 'SELECT title, "desc" FROM services ORDER BY position'):
|
||||||
|
d = _doc(
|
||||||
|
f"خدمت «{_clean(row['title'])}»: {_clean(row['desc'])}",
|
||||||
|
"service", _clean(row["title"]), "/services",
|
||||||
|
)
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
|
# --- پرسشهای متداول ---
|
||||||
|
for row in _fetch_rows(dsn, "SELECT question, answer FROM faqs ORDER BY position"):
|
||||||
|
d = _doc(
|
||||||
|
f"پرسش: {_clean(row['question'])}\nپاسخ: {_clean(row['answer'])}",
|
||||||
|
"faq", _clean(row["question"]), "/faq",
|
||||||
|
)
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
|
# --- پروژهها ---
|
||||||
|
for row in _fetch_rows(
|
||||||
|
dsn,
|
||||||
|
"SELECT slug, title, location, year, status, area, floors, units, client, summary, body FROM projects ORDER BY position",
|
||||||
|
):
|
||||||
|
meta = "\n".join(filter(None, [
|
||||||
|
f"مکان: {_clean(row['location'])}" if row["location"] else "",
|
||||||
|
f"سال: {_clean(row['year'])}" if row["year"] else "",
|
||||||
|
f"وضعیت: {'در حال ساخت' if row['status'] == 'progress' else 'تکمیلشده'}",
|
||||||
|
f"متراژ: {_clean(row['area'])}" if row["area"] else "",
|
||||||
|
f"طبقات: {_clean(row['floors'])}" if row["floors"] else "",
|
||||||
|
f"واحدها: {_clean(row['units'])}" if row["units"] else "",
|
||||||
|
f"کارفرما: {_clean(row['client'])}" if row["client"] else "",
|
||||||
|
]))
|
||||||
|
text = "\n".join(filter(None, [meta, _clean(row["summary"]), body_to_text(row["body"])]))
|
||||||
|
d = _doc(text, "project", _clean(row["title"]), _PROJECT_URL.format(slug=row["slug"]))
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
|
# --- مقالهها (فقط منتشرشده) ---
|
||||||
|
for row in _fetch_rows(
|
||||||
|
dsn,
|
||||||
|
"SELECT slug, title, date, category, excerpt, body FROM articles WHERE status = 'published' ORDER BY position",
|
||||||
|
):
|
||||||
|
text = "\n".join(filter(None, [
|
||||||
|
f"دستهبندی: {_clean(row['category'])}" if row["category"] else "",
|
||||||
|
_clean(row["excerpt"]),
|
||||||
|
body_to_text(row["body"]),
|
||||||
|
]))
|
||||||
|
d = _doc(text, "article", _clean(row["title"]), _ARTICLE_URL.format(slug=row["slug"]))
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
|
# --- مدلهای سرمایهگذاری ---
|
||||||
|
for row in _fetch_rows(dsn, 'SELECT title, "desc", points FROM investment_models ORDER BY position'):
|
||||||
|
points = "؛ ".join(_clean(p) for p in (row["points"] or []))
|
||||||
|
d = _doc(
|
||||||
|
"\n".join(filter(None, [
|
||||||
|
f"«{_clean(row['title'])}»: {_clean(row['desc'])}",
|
||||||
|
f"نکات: {points}" if points else "",
|
||||||
|
])),
|
||||||
|
"investment", _clean(row["title"]), "/investment",
|
||||||
|
)
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
|
# --- مراحل همکاری ---
|
||||||
|
for row in _fetch_rows(dsn, 'SELECT step, title, "desc" FROM process_steps ORDER BY position'):
|
||||||
|
d = _doc(
|
||||||
|
f"مرحله {_clean(row['step'])} — {_clean(row['title'])}: {_clean(row['desc'])}",
|
||||||
|
"process", _clean(row["title"]), "/investment",
|
||||||
|
)
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
|
# --- چرا ما / تیم / نظرسنجی / تایملاین / داستان ما ---
|
||||||
|
for row in _fetch_rows(dsn, 'SELECT title, "desc" FROM why_us ORDER BY position'):
|
||||||
|
d = _doc(f"{_clean(row['title'])}: {_clean(row['desc'])}", "about", _clean(row["title"]), "/about")
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
|
for row in _fetch_rows(dsn, "SELECT name, role, bio FROM team ORDER BY position"):
|
||||||
|
d = _doc(
|
||||||
|
f"{_clean(row['name'])} — {_clean(row['role'])}. {_clean(row['bio'])}",
|
||||||
|
"team", _clean(row["name"]), "/about",
|
||||||
|
)
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
|
for row in _fetch_rows(dsn, "SELECT name, role, quote FROM testimonials ORDER BY position"):
|
||||||
|
d = _doc(
|
||||||
|
f"نظر {_clean(row['name'])} ({_clean(row['role'])}): {_clean(row['quote'])}",
|
||||||
|
"testimonial", _clean(row["name"]), "/",
|
||||||
|
)
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
|
for row in _fetch_rows(dsn, 'SELECT year, title, "desc" FROM timeline ORDER BY position'):
|
||||||
|
d = _doc(
|
||||||
|
f"سال {_clean(row['year'])} — {_clean(row['title'])}: {_clean(row['desc'])}",
|
||||||
|
"timeline", _clean(row["title"]), "/about",
|
||||||
|
)
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
|
story_rows = _fetch_rows(dsn, "SELECT title, paragraphs, quote FROM story LIMIT 1")
|
||||||
|
if story_rows and story_rows[0]:
|
||||||
|
s = story_rows[0]
|
||||||
|
paragraphs = "\n".join(_clean(p) for p in (s["paragraphs"] or []) if _clean(p))
|
||||||
|
text = "\n".join(filter(None, [_clean(s["title"]), paragraphs, _clean(s["quote"])]))
|
||||||
|
d = _doc(text, "about", _clean(s["title"]) or "درباره ما", "/about")
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
|
log.info("Fetched %d documents from site database", len(docs))
|
||||||
|
return docs
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_documents(docs: list[dict], chunk_size: int = 700, overlap: int = 120) -> list[dict]:
|
||||||
|
"""متنهای بلند (مقاله/پروژه) را به قطعات همپوشان تقسیم میکند.
|
||||||
|
|
||||||
|
هر قطعه یک doc_id پایدار میگیرد («type:ترتیب در همان url:شماره قطعه») تا
|
||||||
|
همگامسازی افزایشی pgvector بتواند فقط سطرهای تغییر یافته را امبد کند.
|
||||||
|
"""
|
||||||
|
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
||||||
|
|
||||||
|
from .vectorstore import chunk_content_hash
|
||||||
|
|
||||||
|
splitter = RecursiveCharacterTextSplitter(
|
||||||
|
chunk_size=chunk_size,
|
||||||
|
chunk_overlap=overlap,
|
||||||
|
separators=["\n", "۔", ".", "،", " ", ""],
|
||||||
|
)
|
||||||
|
|
||||||
|
# شماره ردیف هر سند در میان سندهای همtype و هم-url (برای FAQs که url یکسان دارند)
|
||||||
|
occurrence: dict[str, int] = {}
|
||||||
|
|
||||||
|
out: list[dict] = []
|
||||||
|
for doc in docs:
|
||||||
|
key = f"{doc['type']}|{doc['url']}"
|
||||||
|
occ = occurrence.get(key, 0)
|
||||||
|
occurrence[key] = occ + 1
|
||||||
|
|
||||||
|
pieces = splitter.split_text(doc["text"]) if len(doc["text"]) > chunk_size * 1.3 else [doc["text"]]
|
||||||
|
for i, piece in enumerate(pieces, 1):
|
||||||
|
chunk = {
|
||||||
|
"doc_id": f"{doc['type']}:{occ}:{i}",
|
||||||
|
"text": piece if len(pieces) == 1 else f"[{doc['title']} — بخش {i} از {len(pieces)}]\n{piece}",
|
||||||
|
"type": doc["type"],
|
||||||
|
"title": doc["title"],
|
||||||
|
"url": doc["url"],
|
||||||
|
}
|
||||||
|
chunk["content_hash"] = chunk_content_hash(chunk)
|
||||||
|
out.append(chunk)
|
||||||
|
|
||||||
|
log.info("Chunked %d documents into %d pieces", len(docs), len(out))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def content_fingerprint(docs: list[dict]) -> str:
|
||||||
|
"""هش کل محتوا — فقط برای لاگ؛ تشخیص تغییر ردیفی در vectorstore انجام میشود."""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
|
||||||
|
payload = json.dumps(docs, ensure_ascii=False, sort_keys=True)
|
||||||
|
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
# --- زمینه همیشهتازه سایت (بلوک کوتاه تماس/خدمات برای پرامپت) ---
|
||||||
|
_site_context_cache: tuple[float, str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_site_context(database_url: str, ttl: int = 120) -> str:
|
||||||
|
"""نام برند، تماس و فهرست خدمات — با کش کوتاهمدت تا هر پیام یک کوئری سبک بزند."""
|
||||||
|
global _site_context_cache
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
now = _time.time()
|
||||||
|
if _site_context_cache and now - _site_context_cache[0] < ttl:
|
||||||
|
return _site_context_cache[1]
|
||||||
|
try:
|
||||||
|
dsn = _psycopg_dsn(database_url)
|
||||||
|
brand = _fetch_rows(dsn, "SELECT name, phone, email, address FROM brand LIMIT 1")
|
||||||
|
services = _fetch_rows(dsn, "SELECT title FROM services ORDER BY position")
|
||||||
|
lines: list[str] = []
|
||||||
|
if brand:
|
||||||
|
b = brand[0]
|
||||||
|
lines += [
|
||||||
|
f"نام مجموعه: {_clean(b['name'])}",
|
||||||
|
f"تلفن تماس: {_clean(b['phone'])}",
|
||||||
|
f"ایمیل: {_clean(b['email'])}",
|
||||||
|
f"آدرس: {_clean(b['address'])}",
|
||||||
|
]
|
||||||
|
if services:
|
||||||
|
lines.append("خدمات سایت: " + "، ".join(_clean(s["title"]) for s in services))
|
||||||
|
ctx = "\n".join(lines)
|
||||||
|
_site_context_cache = (now, ctx)
|
||||||
|
return ctx
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Site context fetch failed: %s", e)
|
||||||
|
return _site_context_cache[1] if _site_context_cache else ""
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# گزارش ارزیابی چتبات — 2026-10-01 21:05
|
||||||
|
|
||||||
|
- مجموع پرسشها: 21 — درست: 17 — خطا: 0
|
||||||
|
- بازبینی دستی: 4 — خطای فنی: 0
|
||||||
|
- میانگین زمان پاسخ: 9.1s
|
||||||
|
|
||||||
|
## [pass] پروژه میرزا طاهر ۵ کجا ساخته شده و چند متر زیربنا دارد؟
|
||||||
|
*موضوع: projects — 8.2s — منابع: آرامش - میرزا طاهر 4 — /projects/آرامش---میرزا-طاهر-4، آرامش- میرزا طاهر 1 — /projects/آرامش--میرزا-طاهر-1، آرامش میرزا طاهر 2 — /projects/آرامش-میرزا-طاهر-2، آرامش - میرزا طاهر 5 — /projects/آرامش---میرزا-طاهر-5*
|
||||||
|
|
||||||
|
پروژه «آرامش - میرزا طاهر 5» در اصفهان، وحدت، شهریار (محلهی زهران) ساخته شده و متراژ زیربنای آن ۸۱۰۰ مترمربع است.
|
||||||
|
جزئیات کامل در صفحه /projects/آرامش---میرزا-طاهر-5
|
||||||
|
|
||||||
|
## [pass] پروژه آرامش - بابک کی و کجا تحویل شده؟
|
||||||
|
*موضوع: projects — 10.4s — منابع: معرفی صفحه اصلی — /، آخرین مقالات — /blog، آرامش - بابک — /projects/آرامش---بابک، پروژهها — /projects، تحویل و پشتیبانی — /investment*
|
||||||
|
|
||||||
|
پروژه «آرامش - بابک» طبق اطلاعات سایت در سال ۱۴۰۵ تکمیل شده و در اصفهان، شهید رضایی، بین باغ مسجد و ارشاد (محله برزان) قرار دارد.
|
||||||
|
جزئیات کامل در صفحه /projects/آرامش---بابک در دسترس است.
|
||||||
|
|
||||||
|
## [pass] وضعیت پروژه میرزاطاهر ۶ چطور است؟
|
||||||
|
*موضوع: projects — 8.7s — منابع: آرامش - میرزاطاهر 6 — /projects/آرامش---میرزاطاهر-6، آرامش - میرزا طاهر 5 — /projects/آرامش---میرزا-طاهر-5، کوثر- میرزاطاهر — /projects/کوثر--میرزاطاهر، نیکان میرزاطاهر — /projects/نیکان-میرزاطاهر، نگین- میرزاطاهر — /projects/نگین--میرزاطاهر*
|
||||||
|
|
||||||
|
پروژه «آرامش - میرزاطاهر ۶» در اصفهان، میرزاطاهر شرقی (محله زهران) قرار دارد و وضعیت آن **در حال ساخت** است؛ عرصه نیز **در حال اخذ مجوز** است.
|
||||||
|
جزئیات کامل در صفحه /projects/آرامش---میرزاطاهر-6 در دسترس است.
|
||||||
|
|
||||||
|
## [pass] کدام پروژه شما در محله گورتان اصفهان است؟
|
||||||
|
*موضوع: projects — 8.2s — منابع: آرامش - وحدت — /projects/آرامش---وحدت، سالهای توسعه و تجربه — /about، آرامش - آتشگاه — /projects/آرامش---آتشگاه*
|
||||||
|
|
||||||
|
پروژه «آرامش - وحدت» در اصفهان، آتشگاه، محلهی گورتان واقع شده و وضعیت آن تکمیلشده است. این پروژه کاربری مسکونی-تجاری دارد و شامل ۸ واحد مسکونی و ۱ واحد تجاری است.
|
||||||
|
|
||||||
|
جزئیات کامل در صفحه /projects/آرامش---وحدت موجود است.
|
||||||
|
|
||||||
|
## [pass] پروژه میرزا طاهر ۵ چند واحد مسکونی دارد؟
|
||||||
|
*موضوع: projects — 9.0s — منابع: آرامش - میرزا طاهر 4 — /projects/آرامش---میرزا-طاهر-4، آرامش- میرزا طاهر 1 — /projects/آرامش--میرزا-طاهر-1، آرامش - میرزا طاهر 5 — /projects/آرامش---میرزا-طاهر-5، نگین- میرزاطاهر — /projects/نگین--میرزاطاهر، آرامش میرزا طاهر 2 — /projects/آرامش-میرزا-طاهر-2*
|
||||||
|
|
||||||
|
پروژه «آرامش - میرزا طاهر 5» دارای **۲۹ واحد مسکونی** است.
|
||||||
|
جزئیات کامل در صفحه /projects/آرامش---میرزا-طاهر-5
|
||||||
|
|
||||||
|
## [pass] گروه ساختمانی آرامش چند سال سابقه دارد؟
|
||||||
|
*موضوع: stats — 10.3s — منابع: آخرین مقالات — /blog، شرکت آرامش از چه سالی فعالیت میکند؟ — /faq، درباره ما — /about، تجربه بیش از دو دهه — /about، گروه ساختمانی آرامش — /about*
|
||||||
|
|
||||||
|
گروه ساختمانی آرامش بیش از ۲۳ سال سابقه فعالیت در حوزه ساختوساز دارد و فعالیت مستمر خود را از سال ۱۳۸۱ آغاز کرده است.
|
||||||
|
|
||||||
|
## [pass] تا امروز چند پروژه تحویل دادهاید؟
|
||||||
|
*موضوع: stats — 8.7s — منابع: پروژهها — /projects، آمار و افتخارات — /، تعهدی فراتر از تحویل ساختمان — /about، تحویل و پشتیبانی — /investment*
|
||||||
|
|
||||||
|
طبق اطلاعات ثبتشده در سایت، گروه ساختمانی آرامش تاکنون **۶۴ پروژه تحویلداده** است.
|
||||||
|
|
||||||
|
## [pass] مجموع متراژ ساختوساز شما چقدر است؟
|
||||||
|
*موضوع: stats — 9.2s — منابع: آمار و افتخارات — /، شروع مسیر ساختوساز — /about، سرمایهگذاری — /investment، درباره ما — /about، گروه ساختمانی آرامش — /about*
|
||||||
|
|
||||||
|
مجموع زیربنای ساختوساز گروه ساختمانی آرامش ۱۱۱ هزار مترمربع است.
|
||||||
|
|
||||||
|
## [pass] چند خانواده در پروژههای شما ساکن هستند؟
|
||||||
|
*موضوع: stats — 7.2s — منابع: آمار و افتخارات — /*
|
||||||
|
|
||||||
|
بر اساس آمار ثبتشده، بیش از ۵۰۰ خانواده در پروژههای گروه ساختمانی آرامش ساکن هستند.
|
||||||
|
|
||||||
|
## [pass] شماره تماس شما چیست؟
|
||||||
|
*موضوع: contact — 6.6s — منابع: اطلاعات تماس — /contact*
|
||||||
|
|
||||||
|
شماره تماس گروه ساختمانی آرامش: **۰۹۱۳۳۱۰۷۸۰۹**
|
||||||
|
جزئیات بیشتر در صفحه «تماس با ما» موجود است.
|
||||||
|
|
||||||
|
## [pass] دفتر تهران کجاست؟
|
||||||
|
*موضوع: offices — 7.6s — منابع: دفتر تهران — /contact*
|
||||||
|
|
||||||
|
دفتر تهران گروه ساختمانی آرامش در خیابان ولیعصر، برج آرامش، طبقه هجدهم واقع شده است.
|
||||||
|
جزئیات بیشتر در صفحه /contact در دسترس است.
|
||||||
|
|
||||||
|
## [pass] آدرس ایمیل مجموعه چیست؟
|
||||||
|
*موضوع: contact — 8.7s — منابع: اطلاعات تماس — /contact، دفتر نوشهر — /contact، دفتر اصفهان — /contact، تماس با ما — /contact*
|
||||||
|
|
||||||
|
ایمیل مجموعه: arameshsakhtemani@gmail.com
|
||||||
|
|
||||||
|
## [pass] دفاتر شما در چه شهرهایی است؟
|
||||||
|
*موضوع: offices — 9.2s — منابع: ساختمان اداری — /services، ساختمان تجاری — /services*
|
||||||
|
|
||||||
|
دفاتر گروه ساختمانی آرامش در سه شهر زیر فعال هستند:
|
||||||
|
|
||||||
|
- **تهران:** خیابان ولیعصر، برج آرامش، طبقه هجدهم
|
||||||
|
- **اصفهان:** خیابان چهارباغ بالا، مجتمع پارسه، طبقه ششم
|
||||||
|
- **نوشهر:** بلوار دریا، دفتر پروژه دریاکنار
|
||||||
|
|
||||||
|
## [pass] گروه آرامش چه سالی تأسیس شده؟
|
||||||
|
*موضوع: faqs — 18.5s — منابع: شرکت آرامش از چه سالی فعالیت میکند؟ — /faq، آخرین مقالات — /blog، ورود به صنعت ساختمان — /about*
|
||||||
|
|
||||||
|
بر اساس اطلاعات موجود، شرکت ساختمانی آرامش از سال ۱۳۸۱ فعالیت خود را آغاز کرده است. همچنین در صفحه «درباره ما» سابقه ورود مجموعه به صنعت ساختمان از سال ۱۳۷۷ با تأسیس شرکت پیمانکاری ذکر شده است. جزئیات بیشتر در صفحات /faq و /about در دسترس است.
|
||||||
|
|
||||||
|
## [manual] زمین دارم، ساخت را از کجا شروع کنم؟
|
||||||
|
*موضوع: faqs — 7.9s — منابع: اگر زمین داشته باشیم، از کجا باید برای ساخت شروع کنیم؟ — /faq*
|
||||||
|
|
||||||
|
اولین قدم، بررسی زمین و شرایط پروژه است. موقعیت، ضوابط و تراکم، شرایط فنی و ظرفیت اقتصادی زمین بررسی میشود تا پیش از ورود به طراحی و اجرا، مسیر مناسب پروژه مشخص شود. جزئیات کامل در صفحه /faq موجود است.
|
||||||
|
|
||||||
|
## [manual] شرایط مشارکت در ساخت چگونه است؟
|
||||||
|
*موضوع: investment — 8.5s — منابع: آیا امکان مشارکت در ساخت هم دارید؟ — /faq، مشارکت در ساخت — /investment، وبلاگ — /blog، تحویل و پشتیبانی — /investment*
|
||||||
|
|
||||||
|
شرایط مشارکت در ساخت به این صورت است که مالک زمین و گروه ساختمانی آرامش در سود پروژه شریک میشوند و اجرا، دریافت مجوزها و فروش بر عهده ماست. ابتدا ملک بهصورت کارشناسی بررسی میشود، سپس قرارداد شفاف با ضمانت اجرا تنظیم میگردد و در طول پروژه گزارش پیشرفت ارائه میشود. جزئیات کامل در صفحه /investment قابل مشاهده است.
|
||||||
|
|
||||||
|
## [pass] مدل صندوق سرمایهگذاری چطور کار میکند؟
|
||||||
|
*موضوع: investment — 8.1s — منابع: صندوق سرمایهگذاری — /investment، مدلهای همکاری — /investment، سرمایهگذاری ساختمانی — /services، سرمایهگذاری — /investment*
|
||||||
|
|
||||||
|
صندوق سرمایهگذاری به این صورت کار میکند که سرمایهگذاران واحدهایی از پروژههای آرامش را خریداری میکنند و بازدهی آنها در پایان دوره محاسبه میشود. حداقل دوره سرمایهگذاری ۲۴ ماه است، گزارش ارزشگذاری بهصورت فصلی ارائه میشود و امکان انتقال واحد سرمایه نیز وجود دارد.
|
||||||
|
|
||||||
|
جزئیات کامل در صفحه /investment در دسترس است.
|
||||||
|
|
||||||
|
## [pass] پیشخرید واحد یعنی چه؟
|
||||||
|
*موضوع: investment — 8.0s — منابع: پیشخرید واحد — /investment*
|
||||||
|
|
||||||
|
پیشخرید واحد یعنی خرید واحد مسکونی، اداری یا تجاری در مراحل مختلف ساخت. از جمله نکات آن میتوان به امکان شخصیسازی پلان، تضمین زمان تحویل و پرداخت توافقی اشاره کرد. جزئیات کامل در صفحه /investment.
|
||||||
|
|
||||||
|
## [manual] خدمات گروه آرامش چیست؟
|
||||||
|
*موضوع: services — 7.7s — منابع: خدمات شرکت آرامش شامل چه مواردی است؟ — /faq، آخرین مقالات — /blog، آیا امکان مشارکت در ساخت هم دارید؟ — /faq، همراهی از صفر تا صد — /about*
|
||||||
|
|
||||||
|
خدمات گروه ساختمانی آرامش شامل طراحی معماری، ساخت ساختمانهای مسکونی، تجاری و اداری، طراحی و ویلاسازی، طراحی فضاهای عمومی و رفاهی، بازسازی، مدیریت پروژه و سرمایهگذاری ساختمانی است. ما خدمات کامل صفر تا صد را از طراحی تا تحویل کلید پوشش میدهیم. جزئیات بیشتر در صفحه /faq قابل مشاهده است.
|
||||||
|
|
||||||
|
## [manual] نظرات مشتریان قبلی درباره شما چیست؟
|
||||||
|
*موضوع: testimonials — 11.2s — منابع: همراهی با تغییر نیازهای بازار مسکن — /about، نظرات مشتریان — /، سوالات متداول — /faq، سوالات متداول — /faq*
|
||||||
|
|
||||||
|
بر اساس نظرات ثبتشده در سایت، مشتریان قبلی رضایت خود را اینگونه اعلام کردهاند:
|
||||||
|
|
||||||
|
- مهندس سارا کیانی، سرمایهگذار پروژه پارسه: گزارشهای ماهانه آنقدر دقیق بود که نیازی به نگرانی درباره سرمایه نبود و پروژه دو ماه زودتر از موعد به بهرهبرداری رسید.
|
||||||
|
- دکتر امیرحسین راد، ساکن برج الهیه: کیفیت اجرا در جزئیاتی مانند عایق صوتی پنجرهها و نور غیرمستقیم راهروها دیده میشود.
|
||||||
|
- مریم دلاور، مدیر مجموعه ویلاهای دریاکنار: همکاری با تیم طراحی حرفهای بود و ایدهها به زبان معماری ترجمه شد.
|
||||||
|
|
||||||
|
جزئیات بیشتر در صفحه اصلی سایت (/) در بخش نظرات مشتریان آمده است.
|
||||||
|
|
||||||
|
## [fallback-expected] هزینه ساخت هر متر مربع چقدر است؟
|
||||||
|
*موضوع: canary — 9.7s — منابع: آمار و افتخارات — /، اجرا و کنترل کیفیت — /investment، مدیریت پروژه — /services، شفافیت کامل — /about*
|
||||||
|
|
||||||
|
متأسفانه اطلاعات دقیقی در این باره در سایت ثبت نشده است. برای راهنمایی بیشتر میتوانید از صفحه «تماس با ما» با ما در ارتباط باشید.
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"""گراف گفتگو (LangGraph) — پورت خط لوله v13 در سه گام:
|
||||||
|
|
||||||
|
۱) بازنویسی پرسش با تاریخچه (فقط وقتی تاریخچه هست — صرفهجویی در تأخیر)
|
||||||
|
۲) بازیابی ترکیبی با چند نوعواژه (اختیاری)
|
||||||
|
۳) تولید پاسخ فارسی با قواعد تکیه بر داده
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import json as _json
|
||||||
|
from typing import Annotated, List, Optional
|
||||||
|
|
||||||
|
from typing_extensions import TypedDict
|
||||||
|
|
||||||
|
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
||||||
|
from langchain_openai import ChatOpenAI
|
||||||
|
from langgraph.checkpoint.memory import MemorySaver
|
||||||
|
from langgraph.graph import END, StateGraph
|
||||||
|
from langgraph.graph.message import add_messages
|
||||||
|
|
||||||
|
from . import content as content_mod
|
||||||
|
from .config import settings
|
||||||
|
from .retrieval import retrieve_final
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
FALLBACK_MESSAGE = (
|
||||||
|
"متأسفانه اطلاعات دقیقی در این باره در سایت ثبت نشده است. "
|
||||||
|
"برای راهنمایی بیشتر میتوانید از صفحه «تماس با ما» با ما در ارتباط باشید."
|
||||||
|
)
|
||||||
|
LLM_UNAVAILABLE = (
|
||||||
|
"سرویس هوش مصنوعی هنوز پیکربندی نشده است. "
|
||||||
|
"متغیرهای LLM_BASE_URL و LLM_MODEL را در تنظیمات سرویس چتبات تنظیم کنید."
|
||||||
|
)
|
||||||
|
|
||||||
|
_llm = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_llm() -> ChatOpenAI | None:
|
||||||
|
"""LLM تولید — اگر پیکربندی نشده باشد None برمیگرداند."""
|
||||||
|
global _llm
|
||||||
|
if _llm is None and settings.llm_base_url:
|
||||||
|
_llm = ChatOpenAI(
|
||||||
|
model=settings.llm_model,
|
||||||
|
base_url=settings.llm_base_url,
|
||||||
|
api_key=settings.llm_api_key or "not-needed",
|
||||||
|
temperature=0,
|
||||||
|
streaming=True,
|
||||||
|
)
|
||||||
|
return _llm
|
||||||
|
|
||||||
|
|
||||||
|
class GraphState(TypedDict):
|
||||||
|
messages: Annotated[List, add_messages]
|
||||||
|
documents: List[str]
|
||||||
|
contextualized_query: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
# --- گام ۱: بازنویسی پرسش با تاریخچه ---
|
||||||
|
|
||||||
|
def contextualize_query(question: str, history: list) -> str:
|
||||||
|
"""ضمیرها و اشارههای پرسش را با تاریخچه گفتگو مشخص میکند («اون دومی» → موضوع واقعی)."""
|
||||||
|
model = get_llm()
|
||||||
|
if model is None or not history:
|
||||||
|
return question
|
||||||
|
try:
|
||||||
|
history_text = "\n".join(
|
||||||
|
f"{'کاربر' if isinstance(m, HumanMessage) else 'دستیار'}: {str(m.content)[:300]}"
|
||||||
|
for m in history[-6:]
|
||||||
|
)
|
||||||
|
prompt = (
|
||||||
|
"با توجه به تاریخچه گفتگو، پرسش کاربر را به یک پرسش مستقل و کامل فارسی بازنویسی کن.\n"
|
||||||
|
"- ضمیرها و اشارهها (او، آن، همین، دومی، این یکی) را به موضوع واقعیِ ذکرشده در تاریخچه تبدیل کن.\n"
|
||||||
|
"- اگر پرسش از قبل مستقل است، عیناً همان را برگردان.\n"
|
||||||
|
"- فقط متن پرسش بازنویسیشده را برگردان؛ هیچ توضیحی ننویس.\n\n"
|
||||||
|
f"تاریخچه گفتگو:\n{history_text}\n\n"
|
||||||
|
f"پرسش کاربر: {question}\n\n"
|
||||||
|
"پرسش مستقل:"
|
||||||
|
)
|
||||||
|
resp = model.invoke([HumanMessage(content=prompt)], config={"tags": ["utility"]})
|
||||||
|
rewritten = (resp.content or "").strip() if isinstance(resp.content, str) else ""
|
||||||
|
if not rewritten or len(rewritten) > len(question) * 4:
|
||||||
|
return question
|
||||||
|
if rewritten != question:
|
||||||
|
log.info("Contextualized: '%s' → '%s'", question[:60], rewritten[:60])
|
||||||
|
return rewritten
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Contextualization failed: %s", e)
|
||||||
|
return question
|
||||||
|
|
||||||
|
|
||||||
|
def expand_query(query: str) -> list[str]:
|
||||||
|
"""گسترش پرسش به چند نوعواژه (اختیاری — پیشفرض خاموش برای پیکره کوچک)."""
|
||||||
|
model = get_llm()
|
||||||
|
if model is None:
|
||||||
|
return [query]
|
||||||
|
try:
|
||||||
|
prompt = (
|
||||||
|
"برای جستوجو در محتوای سایت یک گروه ساختمانی، ۳ نوعواژه جایگزین برای این پرسش بساز: "
|
||||||
|
"بازنویسی معنایی، استخراج کلیدواژه، گسترش با مفاهیم مرتبط.\n"
|
||||||
|
'فقط JSON برگردان: {"variants": ["۱", "۲", "۳"]}\n\n'
|
||||||
|
f"پرسش: {query}"
|
||||||
|
)
|
||||||
|
resp = model.invoke([HumanMessage(content=prompt)], config={"tags": ["utility"]})
|
||||||
|
text = resp.content if isinstance(resp.content, str) else ""
|
||||||
|
match = re.search(r'\{.*"variants".*\}', text, re.DOTALL)
|
||||||
|
if match:
|
||||||
|
variants = [v for v in _json.loads(match.group()).get("variants", []) if v and v != query][:3]
|
||||||
|
if variants:
|
||||||
|
log.info("Query variants: %s", [query] + variants)
|
||||||
|
return [query] + variants
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Query expansion failed: %s", e)
|
||||||
|
return [query]
|
||||||
|
|
||||||
|
|
||||||
|
# --- گام ۲: بازیابی ---
|
||||||
|
|
||||||
|
def retrieve_node(state: GraphState) -> dict:
|
||||||
|
messages = state.get("messages", [])
|
||||||
|
if not messages:
|
||||||
|
return {"documents": []}
|
||||||
|
question = messages[-1].content
|
||||||
|
history = messages[:-1]
|
||||||
|
|
||||||
|
query = contextualize_query(question, history) if settings.contextualize_enabled else question
|
||||||
|
variants = expand_query(query) if settings.query_expansion else [query]
|
||||||
|
|
||||||
|
docs = retrieve_final(query, variants)
|
||||||
|
doc_texts = [f"{d['text']}\n(منبع: {d['title']} — {d['url']})" for d in docs]
|
||||||
|
log.info("Retrieved %d documents for '%s'", len(doc_texts), question[:60])
|
||||||
|
return {
|
||||||
|
"documents": doc_texts,
|
||||||
|
"contextualized_query": query if query != question else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- گام ۳: تولید پاسخ ---
|
||||||
|
|
||||||
|
def build_system_prompt(documents: list[str], contextualized_query: str | None) -> str:
|
||||||
|
site_ctx = content_mod.fetch_site_context(settings.database_url, settings.site_context_ttl)
|
||||||
|
docs_block = "\n\n———\n\n".join(documents) if documents else "(سند مرتبطی یافت نشد)"
|
||||||
|
reminder = (
|
||||||
|
f"\n--- یادآوری ---\nپرسش کاربر پس از بازنویسی این است: «{contextualized_query}»\n\n"
|
||||||
|
if contextualized_query else ""
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"تو دستیار گفتگوی هوشمند سایت «گروه ساختمانی آرامش» هستی و فقط به فارسی پاسخ میدهی.\n"
|
||||||
|
"دانش تو اکیداً محدود به اطلاعات زیر است.\n\n"
|
||||||
|
f"--- اطلاعات پایه سایت (همیشه معتبر) ---\n{site_ctx}\n\n"
|
||||||
|
f"--- اسناد بازیابیشده مرتبط با پرسش ---\n{docs_block}\n"
|
||||||
|
f"{reminder}"
|
||||||
|
"--- قوانین پاسخ ---\n"
|
||||||
|
"۱. فقط بر اساس اطلاعات بالا پاسخ بده؛ بههیچوجه از دانش بیرونی استفاده نکن و چیزی نساز.\n"
|
||||||
|
"۲. پاسخ کوتاه، دقیق و مفید باشد (معمولاً ۲ تا ۵ جمله) و لحن حرفهای و محترمانه داشته باشد.\n"
|
||||||
|
f"۳. اگر اطلاعات کافی در اسناد نیست، فقط همین جمله را بنویس: «{FALLBACK_MESSAGE}»\n"
|
||||||
|
"۴. اگر سند مسیر صفحه مرتبط دارد، در پایان پاسخ آن را معرفی کن (مثلاً: جزئیات کامل در صفحه /blog/... ).\n"
|
||||||
|
"۵. درباره قیمت، زمان دقیق یا هر تعهدی فقط چیزی را بگو که در دادهها آمده است."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_node(state: GraphState) -> dict:
|
||||||
|
messages = state.get("messages", [])
|
||||||
|
question = messages[-1].content if messages else ""
|
||||||
|
|
||||||
|
model = get_llm()
|
||||||
|
if model is None:
|
||||||
|
return {"messages": [AIMessage(content=LLM_UNAVAILABLE)]}
|
||||||
|
|
||||||
|
documents = state.get("documents") or []
|
||||||
|
system = build_system_prompt(documents, state.get("contextualized_query"))
|
||||||
|
log.info("Generating answer with %d docs (system prompt %d chars)", len(documents), len(system))
|
||||||
|
|
||||||
|
history = messages[:-1]
|
||||||
|
reply = model.invoke(
|
||||||
|
[SystemMessage(content=system), *history, HumanMessage(content=question)],
|
||||||
|
config={"tags": ["generate"]},
|
||||||
|
)
|
||||||
|
return {"messages": [reply]}
|
||||||
|
|
||||||
|
|
||||||
|
# --- گراف ---
|
||||||
|
|
||||||
|
memory = MemorySaver()
|
||||||
|
workflow = StateGraph(GraphState)
|
||||||
|
workflow.add_node("retrieve", retrieve_node)
|
||||||
|
workflow.add_node("generate", generate_node)
|
||||||
|
workflow.set_entry_point("retrieve")
|
||||||
|
workflow.add_edge("retrieve", "generate")
|
||||||
|
workflow.add_edge("generate", END)
|
||||||
|
graph = workflow.compile(checkpointer=memory)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""تستهای خالص (بدون مدل/دیتابیس) برای ماژولهای چتبات — اجرا: python test_pure.py"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
from chatbot.content import chunk_documents, tiptap_blocks, body_to_text
|
||||||
|
from chatbot.vectorstore import chunk_content_hash, chatbot_dsn
|
||||||
|
from chatbot.persian import normalize, tokenize
|
||||||
|
|
||||||
|
docs = [
|
||||||
|
{"text": "پاراگراف کوتاه خدمت الف", "type": "service", "title": "الف", "url": "/services"},
|
||||||
|
{"text": "سؤال اول؟", "type": "faq", "title": "س۱", "url": "/faq"},
|
||||||
|
{"text": "سؤال دوم؟", "type": "faq", "title": "س۲", "url": "/faq"},
|
||||||
|
{"text": "آ" * 2000, "type": "article", "title": "مقاله بلند", "url": "/blog/long"},
|
||||||
|
]
|
||||||
|
chunks = chunk_documents(docs)
|
||||||
|
ids = [c["doc_id"] for c in chunks]
|
||||||
|
assert len(ids) == len(set(ids)), f"duplicate ids: {ids}"
|
||||||
|
|
||||||
|
faq_ids = sorted(c["doc_id"] for c in chunks if c["type"] == "faq")
|
||||||
|
assert faq_ids == ["faq:0:1", "faq:1:1"], faq_ids
|
||||||
|
|
||||||
|
art = [c for c in chunks if c["type"] == "article"]
|
||||||
|
assert len(art) >= 2 and art[1]["doc_id"] == "article:0:2", [x["doc_id"] for x in art]
|
||||||
|
assert "بخش 2 از" in art[1]["text"] and "بخش 1 از" in art[0]["text"]
|
||||||
|
|
||||||
|
c = chunks[0]
|
||||||
|
assert c["content_hash"] == chunk_content_hash(c)
|
||||||
|
c2 = dict(c, title="عنوان عوض شد")
|
||||||
|
# hash فیلد کپیشده است — باید با محاسبه دوباره مقایسه شود، نه با فیلد قدیمی
|
||||||
|
assert chunk_content_hash(c2) != c["content_hash"]
|
||||||
|
|
||||||
|
chunks2 = chunk_documents(docs)
|
||||||
|
assert [x["doc_id"] for x in chunks2] == ids
|
||||||
|
assert [x["content_hash"] for x in chunks2] == [x["content_hash"] for x in chunks]
|
||||||
|
|
||||||
|
settings_dsn = chatbot_dsn()
|
||||||
|
assert settings_dsn.endswith("/aramesh_chatbot"), settings_dsn
|
||||||
|
assert "+psycopg" not in settings_dsn
|
||||||
|
|
||||||
|
# نرمالسازی و توکنسازی فارسی
|
||||||
|
assert normalize("كامل يك ۱۲۳") == "کامل یک 123"
|
||||||
|
t = tokenize("کدام شرکت ساختمانی خوب در تهران؟")
|
||||||
|
assert "شرکت" in t and "تهران" in t and "کدام" not in t, t
|
||||||
|
assert "می\u200cسازیم" in tokenize("می\u200cسازیم") and "سازیم" in tokenize("می\u200cسازیم")
|
||||||
|
|
||||||
|
# استخراج متن TipTap و فرمت قدیمی
|
||||||
|
tiptap_doc = {
|
||||||
|
"type": "doc",
|
||||||
|
"content": [
|
||||||
|
{"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "معرفی پروژه"}]},
|
||||||
|
{"type": "paragraph", "content": [
|
||||||
|
{"type": "text", "text": "این برج در تهران "},
|
||||||
|
{"type": "text", "marks": [{"type": "bold"}], "text": "ساخته شد"},
|
||||||
|
{"type": "hardBreak"},
|
||||||
|
{"type": "image", "attrs": {"alt": "نمای بیرونی"}},
|
||||||
|
]},
|
||||||
|
{"type": "bulletList", "content": [
|
||||||
|
{"type": "listItem", "content": [{"type": "paragraph", "content": [{"type": "text", "text": "۲۴ طبقه"}]}]},
|
||||||
|
]},
|
||||||
|
{"type": "paragraph", "content": [{"type": "image", "attrs": {"alt": ""}}]},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
blocks = tiptap_blocks(tiptap_doc)
|
||||||
|
assert blocks == ["معرفی پروژه", "این برج در تهران ساخته شد [تصویر: نمای بیرونی]", "۲۴ طبقه"], blocks
|
||||||
|
assert body_to_text(["الف", "ب"]) == "الف\nب"
|
||||||
|
assert body_to_text(None) == ""
|
||||||
|
|
||||||
|
print(f"ALL PURE TESTS OK — {len(chunks)} chunks from {len(docs)} docs")
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"""ذخیرهساز برداری pgvector — جایگزین FAISS در مهاجرت به pgvector.
|
||||||
|
|
||||||
|
بردارها در یک دیتابیس جدا (aramesh_chatbot) روی همان نمونه Postgres سایت
|
||||||
|
نگهداری میشوند تا دیتابیس سایت فقطخواندنی بماند. همگامسازی افزایشی است:
|
||||||
|
فقط سطرهای جدید/تغییریافته امبد میشوند (کلید: doc_id پایدار + hash محتوا).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_driver(dsn: str) -> str:
|
||||||
|
return dsn.replace("+psycopg", "").replace("+asyncpg", "")
|
||||||
|
|
||||||
|
|
||||||
|
def chatbot_dsn() -> str:
|
||||||
|
"""DSN دیتابیس اختصاصی چتبات — از DATABASE_URL با تعویض نام دیتابیس ساخته میشود."""
|
||||||
|
parts = urlsplit(_strip_driver(settings.database_url))
|
||||||
|
return urlunsplit(parts._replace(path=f"/{settings.chatbot_db_name}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_dsn() -> str:
|
||||||
|
"""DSN دیتابیس postgres برای ساخت دیتابیس چتبات (فقط یکبار)."""
|
||||||
|
parts = urlsplit(_strip_driver(settings.database_url))
|
||||||
|
return urlunsplit(parts._replace(path="/postgres"))
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_schema() -> None:
|
||||||
|
"""ساخت دیتابیس و جدول بردارها — idempotent و در هر شروع اجرا میشود."""
|
||||||
|
import psycopg
|
||||||
|
from psycopg import errors as pg_errors
|
||||||
|
|
||||||
|
try:
|
||||||
|
with psycopg.connect(_admin_dsn(), autocommit=True, connect_timeout=10) as conn:
|
||||||
|
try:
|
||||||
|
conn.execute(f'CREATE DATABASE "{settings.chatbot_db_name}"')
|
||||||
|
log.info("Created chatbot database '%s'", settings.chatbot_db_name)
|
||||||
|
except pg_errors.DuplicateDatabase:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Cannot reach Postgres to ensure chatbot database: {e}") from e
|
||||||
|
|
||||||
|
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10) as conn:
|
||||||
|
conn.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||||
|
conn.execute(
|
||||||
|
f"""
|
||||||
|
CREATE TABLE IF NOT EXISTS chatbot_chunks (
|
||||||
|
doc_id TEXT PRIMARY KEY,
|
||||||
|
content_hash TEXT NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
content JSONB NOT NULL,
|
||||||
|
embedding vector({settings.embedding_dim}) NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS chatbot_chunks_embedding_hnsw
|
||||||
|
ON chatbot_chunks USING hnsw (embedding vector_cosine_ops)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_chunks(chunks: list[dict], force: bool = False) -> dict:
|
||||||
|
"""همگامسازی افزایشی قطعات با جدول pgvector.
|
||||||
|
|
||||||
|
- سطرهایی که دیگر وجود ندارند حذف میشوند
|
||||||
|
- سطرهای جدید یا تغییر یافته (hash یا مدل امبدینگ عوض شده) امبد و upsert میشوند
|
||||||
|
- force=True همه سطرها را از نو امبد میکند
|
||||||
|
خروجی: آمار عملیات برای لاگ.
|
||||||
|
"""
|
||||||
|
import psycopg
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
|
from .embeddings import get_embeddings
|
||||||
|
|
||||||
|
model = settings.embedding_model
|
||||||
|
wanted = {c["doc_id"]: c for c in chunks}
|
||||||
|
|
||||||
|
# row_factory لازم است: پایینتر row["model"]/row["doc_id"] خوانده میشود و
|
||||||
|
# پیشفرض psycopg3 تاپل برمیگرداند (خطای tuple indices must be integers).
|
||||||
|
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10,
|
||||||
|
row_factory=dict_row) as conn:
|
||||||
|
existing: dict[str, str] = {}
|
||||||
|
if not force:
|
||||||
|
for row in conn.execute("SELECT doc_id, content_hash, model FROM chatbot_chunks").fetchall():
|
||||||
|
if row["model"] == model:
|
||||||
|
existing[row["doc_id"]] = row["content_hash"]
|
||||||
|
else:
|
||||||
|
conn.execute("DELETE FROM chatbot_chunks")
|
||||||
|
|
||||||
|
to_delete = [doc_id for doc_id in existing if doc_id not in wanted]
|
||||||
|
if to_delete:
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM chatbot_chunks WHERE doc_id = ANY(%s)",
|
||||||
|
[to_delete],
|
||||||
|
)
|
||||||
|
|
||||||
|
to_upsert = [
|
||||||
|
c for c in chunks
|
||||||
|
if existing.get(c["doc_id"]) != c["content_hash"]
|
||||||
|
]
|
||||||
|
inserted = updated = 0
|
||||||
|
|
||||||
|
if to_upsert:
|
||||||
|
vectors = get_embeddings().embed_documents([c["text"] for c in to_upsert])
|
||||||
|
for chunk, vec in zip(to_upsert, vectors):
|
||||||
|
literal = "[" + ",".join(f"{x:.7f}" for x in vec) + "]"
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO chatbot_chunks (doc_id, content_hash, model, content, embedding)
|
||||||
|
VALUES (%s, %s, %s, %s, %s::vector)
|
||||||
|
ON CONFLICT (doc_id) DO UPDATE SET
|
||||||
|
content_hash = EXCLUDED.content_hash,
|
||||||
|
model = EXCLUDED.model,
|
||||||
|
content = EXCLUDED.content,
|
||||||
|
embedding = EXCLUDED.embedding
|
||||||
|
""",
|
||||||
|
(chunk["doc_id"], chunk["content_hash"], model, Jsonb(chunk), literal),
|
||||||
|
)
|
||||||
|
if chunk["doc_id"] in existing:
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
inserted += 1
|
||||||
|
|
||||||
|
stats = {"inserted": inserted, "updated": updated, "deleted": len(to_delete),
|
||||||
|
"unchanged": len(wanted) - len(to_upsert)}
|
||||||
|
log.info("Vector sync: %s", stats)
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_all_chunks() -> list[dict]:
|
||||||
|
"""همه قطعات ذخیرهشده (برای ساخت BM25 در حافظه)."""
|
||||||
|
import psycopg
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
|
||||||
|
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10,
|
||||||
|
row_factory=dict_row) as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT doc_id, content_hash, content FROM chatbot_chunks ORDER BY doc_id"
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"doc_id": r["doc_id"],
|
||||||
|
"content_hash": r["content_hash"],
|
||||||
|
**(r["content"] if isinstance(r["content"], dict) else json.loads(r["content"])),
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def vector_count() -> int:
|
||||||
|
"""تعداد بردارهای ذخیرهشده — برای healthz (خطا → ۰)."""
|
||||||
|
try:
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=5) as conn:
|
||||||
|
return int(conn.execute("SELECT count(*) FROM chatbot_chunks").fetchone()[0])
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def dense_search(query: str, top_k: int) -> list[tuple[dict, float]]:
|
||||||
|
"""جستوجوی معنایی SQL با فاصله کسینوسی — خروجی: (سند، شباهت).
|
||||||
|
|
||||||
|
خطا (مثلاً دیتابیس هنوز آماده نیست) → فهرست خالی تا بازیابی فقط با BM25 ادامه یابد.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import psycopg
|
||||||
|
from psycopg.rows import dict_row as _dict_row
|
||||||
|
|
||||||
|
from .embeddings import get_embeddings
|
||||||
|
|
||||||
|
vec = get_embeddings().embed_query(query)
|
||||||
|
literal = "[" + ",".join(f"{x:.7f}" for x in vec) + "]"
|
||||||
|
with psycopg.connect(chatbot_dsn(), row_factory=_dict_row, connect_timeout=10) as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT doc_id, content, 1 - (embedding <=> %s::vector) AS sim
|
||||||
|
FROM chatbot_chunks
|
||||||
|
ORDER BY embedding <=> %s::vector
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(literal, literal, top_k),
|
||||||
|
).fetchall()
|
||||||
|
return [(_row_to_doc(r), float(r["sim"])) for r in rows]
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Dense search failed (%s) — falling back to keyword only", e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_doc(row: Any) -> dict:
|
||||||
|
content = row["content"] if isinstance(row["content"], dict) else json.loads(row["content"])
|
||||||
|
return {
|
||||||
|
"doc_id": row["doc_id"],
|
||||||
|
"text": content.get("text", ""),
|
||||||
|
"type": content.get("type", ""),
|
||||||
|
"title": content.get("title", ""),
|
||||||
|
"url": content.get("url", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_content_hash(chunk: dict) -> str:
|
||||||
|
"""هش محتوای قطعه — تغییر متن یا متادیتا یعنی سطر باید بهروز شود."""
|
||||||
|
payload = json.dumps(
|
||||||
|
{k: chunk[k] for k in ("text", "type", "title", "url")},
|
||||||
|
ensure_ascii=False, sort_keys=True,
|
||||||
|
)
|
||||||
|
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
|
||||||
+24
-71
@@ -336,15 +336,11 @@ register_crud("footer", models.FooterContent, FooterOut, FooterIn, FooterPatch)
|
|||||||
register_crud("seo-meta", models.SeoMeta, SeoMetaOut, SeoMetaIn, SeoMetaPatch)
|
register_crud("seo-meta", models.SeoMeta, SeoMetaOut, SeoMetaIn, SeoMetaPatch)
|
||||||
|
|
||||||
|
|
||||||
# آپلود رسانه به Object Storage (MinIO) — هر ورودی قابلرمزگشایی به WebP نرمال میشود:
|
# رسانه آپلودی به Object Storage (MinIO) — هر ورودی قابلرمزگشایی به WebP «تکفریمی» نرمال میشود:
|
||||||
# تصویر ثابت → WebP تکفریمی | gif/apng/webp متحرک → WebP متحرک | ویدیو → WebP متحرک با سقف.
|
# هیچ آپلودی هرگز متحرک ذخیره نمیشود — منبع متحرک (gif/apng/webp متحرک/ویدیو) از فریم اول
|
||||||
|
# پوستر میگیرد؛ انیمیشن در کارتها و اسلایدرها سوسو میزند و مطلوب نیست.
|
||||||
# URL نسبی برمیگردد تا بکاند ذخیرهسازی قابل تعویض باشد.
|
# URL نسبی برمیگردد تا بکاند ذخیرهسازی قابل تعویض باشد.
|
||||||
MAX_DIMENSION = 2000 # سقف ضلع تصویر ثابت — ابعاد بزرگ اصلیترین عامل کندی سایت است
|
MAX_DIMENSION = 2000 # سقف ضلع تصویر — ابعاد بزرگ اصلیترین عامل کندی سایت است
|
||||||
MAX_ANIM_DIMENSION = 800 # انیمیشن: سقف ضلع فریم
|
|
||||||
MAX_ANIM_FRAMES = 150 # انیمیشن: سقف فریم
|
|
||||||
VIDEO_MAX_SECONDS = 8 # ویدیو: سقف مدت ورودی
|
|
||||||
VIDEO_FPS = 12 # ویدیو: نرخ فریم خروجی
|
|
||||||
VIDEO_WEBP_LIMIT = 5 * 1024 * 1024 # انیمیشن ویدیویی حجیمتر از این → فقط فریم پوستر
|
|
||||||
WEBP_QUALITY = 82
|
WEBP_QUALITY = 82
|
||||||
WEBP_CONTENT_TYPE = "image/webp"
|
WEBP_CONTENT_TYPE = "image/webp"
|
||||||
|
|
||||||
@@ -360,7 +356,9 @@ def _to_webp_mode(img: Image.Image) -> Image.Image:
|
|||||||
|
|
||||||
|
|
||||||
def _webp_still(img: Image.Image) -> tuple[bytes, int, int]:
|
def _webp_still(img: Image.Image) -> tuple[bytes, int, int]:
|
||||||
"""تکفریم → WebP؛ چرخش EXIF اعمال، متادیتا حذف و سقف ضلع ۲۰۰۰ پیکسل رعایت میشود."""
|
"""فریم اول → WebP تکفریمی؛ چرخش EXIF اعمال، متادیتا حذف و سقف ضلع ۲۰۰۰ پیکسل رعایت میشود."""
|
||||||
|
if getattr(img, "n_frames", 1) > 1:
|
||||||
|
img.seek(0)
|
||||||
img = ImageOps.exif_transpose(img)
|
img = ImageOps.exif_transpose(img)
|
||||||
if max(img.size) > MAX_DIMENSION:
|
if max(img.size) > MAX_DIMENSION:
|
||||||
img.thumbnail((MAX_DIMENSION, MAX_DIMENSION), Image.LANCZOS)
|
img.thumbnail((MAX_DIMENSION, MAX_DIMENSION), Image.LANCZOS)
|
||||||
@@ -370,50 +368,25 @@ def _webp_still(img: Image.Image) -> tuple[bytes, int, int]:
|
|||||||
return buf.getvalue(), *img.size
|
return buf.getvalue(), *img.size
|
||||||
|
|
||||||
|
|
||||||
def _webp_animated(img: Image.Image) -> tuple[bytes, int, int] | None:
|
def _ffprobe_video(path: str) -> dict | None:
|
||||||
"""چندفریم (gif/apng/webp متحرک) → WebP متحرک؛ None یعنی ورودی تکفریمی است.
|
"""استریم ویدیوی اول با ffprobe — None یعنی ویدیویی در فایل نیست (رسانهٔ تصویری نیست)."""
|
||||||
فریمهای بیش از سقف رها میشوند تا حافظه و حجم خروجی مهار شود."""
|
|
||||||
total = getattr(img, "n_frames", 1)
|
|
||||||
if total <= 1:
|
|
||||||
return None
|
|
||||||
count = min(total, MAX_ANIM_FRAMES)
|
|
||||||
frames: list[Image.Image] = []
|
|
||||||
durations: list[int] = []
|
|
||||||
for i in range(count):
|
|
||||||
img.seek(i)
|
|
||||||
durations.append(int(img.info.get("duration") or 100))
|
|
||||||
frame = img.convert("RGBA")
|
|
||||||
if max(frame.size) > MAX_ANIM_DIMENSION:
|
|
||||||
frame.thumbnail((MAX_ANIM_DIMENSION, MAX_ANIM_DIMENSION), Image.LANCZOS)
|
|
||||||
frames.append(frame)
|
|
||||||
buf = io.BytesIO()
|
|
||||||
frames[0].save(
|
|
||||||
buf, "WEBP", save_all=True, append_images=frames[1:],
|
|
||||||
duration=durations, loop=0, quality=WEBP_QUALITY, method=4,
|
|
||||||
)
|
|
||||||
return buf.getvalue(), *frames[0].size
|
|
||||||
|
|
||||||
|
|
||||||
def _ffprobe_video(path: str) -> tuple[dict | None, dict]:
|
|
||||||
"""استریم ویدیوی اول + مشخصات فرمت با ffprobe — استریم None یعنی ویدیویی در فایل نیست."""
|
|
||||||
ffprobe = shutil.which("ffprobe")
|
ffprobe = shutil.which("ffprobe")
|
||||||
if ffprobe is None:
|
if ffprobe is None:
|
||||||
return None, {}
|
return None
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
[ffprobe, "-v", "error", "-print_format", "json", "-show_streams", "-show_format", path],
|
[ffprobe, "-v", "error", "-print_format", "json", "-show_streams", path],
|
||||||
capture_output=True, timeout=20,
|
capture_output=True, timeout=20,
|
||||||
)
|
)
|
||||||
except (subprocess.TimeoutExpired, OSError):
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
return None, {}
|
return None
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
return None, {}
|
return None
|
||||||
try:
|
try:
|
||||||
info = json.loads(proc.stdout or "{}")
|
streams = json.loads(proc.stdout or "{}").get("streams") or []
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return None, {}
|
return None
|
||||||
video = next((s for s in info.get("streams") or [] if s.get("codec_type") == "video"), None)
|
return next((s for s in streams if s.get("codec_type") == "video"), None)
|
||||||
return video, info.get("format") or {}
|
|
||||||
|
|
||||||
|
|
||||||
def _run_ffmpeg(args: list[str]) -> None:
|
def _run_ffmpeg(args: list[str]) -> None:
|
||||||
@@ -429,7 +402,7 @@ def _run_ffmpeg(args: list[str]) -> None:
|
|||||||
|
|
||||||
def _ffmpeg_webp(data: bytes) -> tuple[bytes, int | None, int | None]:
|
def _ffmpeg_webp(data: bytes) -> tuple[bytes, int | None, int | None]:
|
||||||
"""مسیر FFmpeg — ورودیهایی که Pillow نمیخواند: ویدیو و فرمتهای تصویری خاص.
|
"""مسیر FFmpeg — ورودیهایی که Pillow نمیخواند: ویدیو و فرمتهای تصویری خاص.
|
||||||
ویدیو تا ۸ ثانیه با ۱۲fps به WebP متحرک میرسد؛ خروجی حجیم → فقط فریم پوستر."""
|
خروجی همیشه تکفریم (پوستر) است — ویدیوی متحرک در صفحه لوپ میشود و سوسو میزند."""
|
||||||
if shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None:
|
if shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None:
|
||||||
raise HTTPException(status_code=415, detail=SUPPORTED_MEDIA_DETAIL)
|
raise HTTPException(status_code=415, detail=SUPPORTED_MEDIA_DETAIL)
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
@@ -437,30 +410,14 @@ def _ffmpeg_webp(data: bytes) -> tuple[bytes, int | None, int | None]:
|
|||||||
dst = os.path.join(tmp, "out.webp")
|
dst = os.path.join(tmp, "out.webp")
|
||||||
with open(src, "wb") as fh:
|
with open(src, "wb") as fh:
|
||||||
fh.write(data)
|
fh.write(data)
|
||||||
video, fmt = _ffprobe_video(src)
|
if _ffprobe_video(src) is None:
|
||||||
if video is None:
|
|
||||||
raise HTTPException(status_code=415, detail=SUPPORTED_MEDIA_DETAIL)
|
raise HTTPException(status_code=415, detail=SUPPORTED_MEDIA_DETAIL)
|
||||||
ff = shutil.which("ffmpeg") or "ffmpeg"
|
ff = shutil.which("ffmpeg") or "ffmpeg"
|
||||||
animated = float(fmt.get("duration") or 0) >= 0.5
|
_run_ffmpeg([ff, "-y", "-v", "error", "-i", src, "-frames:v", "1",
|
||||||
video_vf = f"fps={VIDEO_FPS},scale='min({MAX_ANIM_DIMENSION},iw)':-2:flags=lanczos"
|
"-vf", f"scale='min({MAX_DIMENSION},iw)':-2:flags=lanczos",
|
||||||
still_vf = f"scale='min({MAX_DIMENSION},iw)':-2:flags=lanczos"
|
"-c:v", "libwebp", "-q:v", str(WEBP_QUALITY), "-map_metadata", "-1", dst])
|
||||||
|
|
||||||
def run_convert(extra: list[str], vf: str) -> None:
|
|
||||||
_run_ffmpeg([ff, "-y", "-v", "error", "-i", src, *extra, "-vf", vf,
|
|
||||||
"-c:v", "libwebp", "-q:v", str(WEBP_QUALITY), "-map_metadata", "-1", dst])
|
|
||||||
|
|
||||||
# ویدیو → متحرک با سقف فریمنرخ/ضلع/مدت؛ تصویر خاص → تکفریم
|
|
||||||
if animated:
|
|
||||||
run_convert(["-t", str(VIDEO_MAX_SECONDS)], video_vf)
|
|
||||||
else:
|
|
||||||
run_convert(["-frames:v", "1"], still_vf)
|
|
||||||
with open(dst, "rb") as fh:
|
with open(dst, "rb") as fh:
|
||||||
out = fh.read()
|
out = fh.read()
|
||||||
# انیمیشن ویدیویی ناگذیر حجیم → فقط فریم پوستر
|
|
||||||
if animated and len(out) > VIDEO_WEBP_LIMIT:
|
|
||||||
run_convert(["-frames:v", "1"], still_vf)
|
|
||||||
with open(dst, "rb") as fh:
|
|
||||||
out = fh.read()
|
|
||||||
# ffprobe روی WebP عرض/ارتفاع صفر برمیگرداند — ابعاد از خود خروجی با Pillow
|
# ffprobe روی WebP عرض/ارتفاع صفر برمیگرداند — ابعاد از خود خروجی با Pillow
|
||||||
try:
|
try:
|
||||||
width, height = Image.open(io.BytesIO(out)).size
|
width, height = Image.open(io.BytesIO(out)).size
|
||||||
@@ -470,14 +427,10 @@ def _ffmpeg_webp(data: bytes) -> tuple[bytes, int | None, int | None]:
|
|||||||
|
|
||||||
|
|
||||||
def _normalize_media(data: bytes) -> tuple[bytes, int | None, int | None]:
|
def _normalize_media(data: bytes) -> tuple[bytes, int | None, int | None]:
|
||||||
"""هر ورودی قابلرمزگشایی → WebP. اول Pillow (دقیقترین برای EXIF/شفافیت/انیمیشن)؛
|
"""هر ورودی قابلرمزگشایی → WebP تکفریمی. اول Pillow (دقیقترین برای EXIF/شفافیت)؛
|
||||||
اگر رمزگشایی نشد، FFmpeg (ویدیو و فرمتهای خاص). هیچکدام نشد → ۴۱۵."""
|
اگر رمزگشایی نشد، FFmpeg (پوستر ویدیو و فرمتهای خاص). هیچکدام نشد → ۴۱۵."""
|
||||||
try:
|
try:
|
||||||
img = Image.open(io.BytesIO(data))
|
return _webp_still(Image.open(io.BytesIO(data)))
|
||||||
animated = _webp_animated(img)
|
|
||||||
if animated is not None:
|
|
||||||
return animated
|
|
||||||
return _webp_still(img)
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -45,6 +45,14 @@ async def _preview_noindex(request, call_next):
|
|||||||
response.headers["X-Robots-Tag"] = "noindex, nofollow, noarchive"
|
response.headers["X-Robots-Tag"] = "noindex, nofollow, noarchive"
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
# پاسخهای API هرگز کش مرورگر/CDN نشوند — دادهٔ پنل بعد از هر ذخیره فوراً fresh بماند
|
||||||
|
@app.middleware("http")
|
||||||
|
async def _api_no_store(request, call_next):
|
||||||
|
response = await call_next(request)
|
||||||
|
if request.url.path.startswith("/api"):
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return response
|
||||||
|
|
||||||
app.include_router(public.router, prefix="/api", tags=["عمومی"])
|
app.include_router(public.router, prefix="/api", tags=["عمومی"])
|
||||||
app.include_router(forms.router, prefix="/api", tags=["فرمها"])
|
app.include_router(forms.router, prefix="/api", tags=["فرمها"])
|
||||||
app.include_router(admin.auth_router, prefix="/api", tags=["احراز هویت"])
|
app.include_router(admin.auth_router, prefix="/api", tags=["احراز هویت"])
|
||||||
|
|||||||
@@ -153,6 +153,16 @@ def run_ddl_migrations() -> None:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''")
|
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''")
|
||||||
)
|
)
|
||||||
|
# نام نویسنده و توضیحات سئوی اختصاصی مقاله — فیلدهایی که پنل قبلاً نمایش میداد ولی در دیتابیس نبودند
|
||||||
|
conn.execute(
|
||||||
|
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS author VARCHAR(128) NOT NULL DEFAULT ''")
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS meta_description TEXT NOT NULL DEFAULT ''")
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS meta_description_en TEXT NOT NULL DEFAULT ''")
|
||||||
|
)
|
||||||
# نقشها — 'admin' همهکاره، 'creator' فقط نویسندهٔ محتوا
|
# نقشها — 'admin' همهکاره، 'creator' فقط نویسندهٔ محتوا
|
||||||
conn.execute(
|
conn.execute(
|
||||||
text("ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS role VARCHAR(16) NOT NULL DEFAULT 'admin'")
|
text("ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS role VARCHAR(16) NOT NULL DEFAULT 'admin'")
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class Brand(Base):
|
|||||||
name: Mapped[str] = mapped_column(String(128), default="")
|
name: Mapped[str] = mapped_column(String(128), default="")
|
||||||
name_en: Mapped[str] = mapped_column(String(128), default="")
|
name_en: Mapped[str] = mapped_column(String(128), default="")
|
||||||
logo: Mapped[str] = mapped_column(Text, default="")
|
logo: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
logo_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
hero: Mapped[str] = mapped_column(Text, default="")
|
hero: Mapped[str] = mapped_column(Text, default="")
|
||||||
contact_bg: Mapped[str] = mapped_column(Text, default="")
|
contact_bg: Mapped[str] = mapped_column(Text, default="")
|
||||||
phone: Mapped[str] = mapped_column(String(64), default="")
|
phone: Mapped[str] = mapped_column(String(64), default="")
|
||||||
@@ -119,10 +120,24 @@ class Article(Base):
|
|||||||
faqs: Mapped[list] = mapped_column(JSON, default=list)
|
faqs: Mapped[list] = mapped_column(JSON, default=list)
|
||||||
faqs_en: Mapped[Optional[list]] = mapped_column(JSON) # NULL یعنی بدون ترجمه — برگشت به فارسی
|
faqs_en: Mapped[Optional[list]] = mapped_column(JSON) # NULL یعنی بدون ترجمه — برگشت به فارسی
|
||||||
author_id: Mapped[int | None] = mapped_column(Integer, nullable=True) # نویسندهٔ creator — برای مقالههای ادمین NULL
|
author_id: Mapped[int | None] = mapped_column(Integer, nullable=True) # نویسندهٔ creator — برای مقالههای ادمین NULL
|
||||||
|
# نام نویسنده (رشتهٔ آزاد) — سربرگ مقاله و اسکیمای Article (نوع Person برای سیگنال E-E-A-T)
|
||||||
|
author: Mapped[str] = mapped_column(String(128), default="")
|
||||||
# draft | published — پیشنویس فقط برای ادمین دیده میشود
|
# draft | published — پیشنویس فقط برای ادمین دیده میشود
|
||||||
status: Mapped[str] = mapped_column(String(16), default="published")
|
status: Mapped[str] = mapped_column(String(16), default="published")
|
||||||
# کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود
|
# کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود
|
||||||
seo_keyphrase: Mapped[str] = mapped_column(String(128), default="")
|
seo_keyphrase: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
# توضیحات سئوی اختصاصی مقاله — در تگ description و اسکیما، در اولویت بر چکیده
|
||||||
|
meta_description: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
meta_description_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
# نشانی انگلیسی مقاله — مسیر /en/blog/...؛ خالی یعنی همان اسلاگ فارسی
|
||||||
|
slug_en: Mapped[str] = mapped_column(String(128), default="", index=True)
|
||||||
|
# کلمه کلیدی هدف نسخه انگلیسی — چکلیست سئو
|
||||||
|
seo_keyphrase_en: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
# نام نویسنده نسخه انگلیسی
|
||||||
|
author_en: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
# هشتگها/برچسبها — هر زبان جدا
|
||||||
|
tags: Mapped[list] = mapped_column(JSON, default=list)
|
||||||
|
tags_en: Mapped[list] = mapped_column(JSON, default=list)
|
||||||
|
|
||||||
|
|
||||||
class Testimonial(Base):
|
class Testimonial(Base):
|
||||||
|
|||||||
+78
-1
@@ -29,6 +29,79 @@ def _clean_url(value: Any) -> str | None:
|
|||||||
return stripped
|
return stripped
|
||||||
|
|
||||||
|
|
||||||
|
def _is_junk_paragraph(node: Any) -> bool:
|
||||||
|
"""پاراگراف خالی یا فقط نویسهٔ خنثی (nbsp/فاصله) — داخل نقلقول حذف میشود."""
|
||||||
|
if not isinstance(node, dict) or node.get("type") != "paragraph":
|
||||||
|
return False
|
||||||
|
content = node.get("content") or []
|
||||||
|
if not content:
|
||||||
|
return True
|
||||||
|
return all(
|
||||||
|
isinstance(item, dict)
|
||||||
|
and item.get("type") == "text"
|
||||||
|
and not str(item.get("text", "")).replace("\xa0", " ").strip()
|
||||||
|
for item in content
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _lift_lists_flat(node: Any) -> list[Any]:
|
||||||
|
"""گره را به دنبالهای از گرههای نرمال تبدیل میکند — نقلقولهای داخل
|
||||||
|
آیتم فهرست به سطح خود فهرست میآیند تا خط مسی همهٔ نقلقولها یکخط شود."""
|
||||||
|
if not isinstance(node, dict):
|
||||||
|
return [node]
|
||||||
|
flat_content: list[Any] = []
|
||||||
|
for child in node.get("content") or []:
|
||||||
|
flat_content.extend(_lift_lists_flat(child))
|
||||||
|
node["content"] = flat_content
|
||||||
|
|
||||||
|
if node.get("type") not in ("bulletList", "orderedList"):
|
||||||
|
return [node]
|
||||||
|
|
||||||
|
rebuilt: list[Any] = []
|
||||||
|
pending_items: list[Any] = []
|
||||||
|
for li in node.get("content") or []:
|
||||||
|
if not isinstance(li, dict) or li.get("type") != "listItem":
|
||||||
|
pending_items.append(li)
|
||||||
|
continue
|
||||||
|
parts: list[list[Any]] = [[]]
|
||||||
|
for child in li.get("content") or []:
|
||||||
|
if isinstance(child, dict) and child.get("type") == "blockquote":
|
||||||
|
parts.append(child)
|
||||||
|
parts.append([])
|
||||||
|
else:
|
||||||
|
parts[-1].append(child)
|
||||||
|
if len(parts) == 1:
|
||||||
|
pending_items.append(li)
|
||||||
|
continue
|
||||||
|
if parts[0]:
|
||||||
|
li["content"] = parts[0]
|
||||||
|
pending_items.append(li)
|
||||||
|
for part in parts[1:]:
|
||||||
|
if isinstance(part, dict) and part.get("type") == "blockquote":
|
||||||
|
if pending_items:
|
||||||
|
rebuilt.append({**node, "content": pending_items})
|
||||||
|
pending_items = []
|
||||||
|
rebuilt.append(part)
|
||||||
|
else:
|
||||||
|
pending_items.extend(part)
|
||||||
|
if pending_items:
|
||||||
|
rebuilt.append({**node, "content": pending_items})
|
||||||
|
return rebuilt
|
||||||
|
|
||||||
|
|
||||||
|
def _lift_doc(doc: Any) -> Any:
|
||||||
|
lifted = _lift_lists_flat(doc)
|
||||||
|
if len(lifted) == 1 and isinstance(lifted[0], dict) and lifted[0].get("type") == "doc":
|
||||||
|
return lifted[0]
|
||||||
|
content: list[Any] = []
|
||||||
|
for part in lifted:
|
||||||
|
if isinstance(part, dict) and part.get("type") == "doc":
|
||||||
|
content.extend(part.get("content") or [])
|
||||||
|
else:
|
||||||
|
content.append(part)
|
||||||
|
return {"type": "doc", "content": content}
|
||||||
|
|
||||||
|
|
||||||
def _walk(node: Any) -> Any:
|
def _walk(node: Any) -> Any:
|
||||||
if isinstance(node, list):
|
if isinstance(node, list):
|
||||||
return [_walk(item) for item in node]
|
return [_walk(item) for item in node]
|
||||||
@@ -48,6 +121,9 @@ def _walk(node: Any) -> Any:
|
|||||||
cleaned[key] = attrs
|
cleaned[key] = attrs
|
||||||
else:
|
else:
|
||||||
cleaned[key] = _walk(value)
|
cleaned[key] = _walk(value)
|
||||||
|
# داخل نقلقول: پاراگرافهای خالی/نویسهٔ خنثی حذف — وگرنه خط خالی اضافه رندر میشود
|
||||||
|
if cleaned.get("type") == "blockquote" and isinstance(cleaned.get("content"), list):
|
||||||
|
cleaned["content"] = [c for c in cleaned["content"] if not _is_junk_paragraph(c)]
|
||||||
return cleaned
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
@@ -55,4 +131,5 @@ def sanitize_doc(doc: Any) -> Any:
|
|||||||
"""سند TipTap را درجا پاک میکند (سایر ساختارها دستنخورده برمیگردند)."""
|
"""سند TipTap را درجا پاک میکند (سایر ساختارها دستنخورده برمیگردند)."""
|
||||||
if not isinstance(doc, dict):
|
if not isinstance(doc, dict):
|
||||||
return doc
|
return doc
|
||||||
return _walk(doc)
|
walked = _walk(doc)
|
||||||
|
return _lift_doc(walked)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class BrandOut(ORMModel):
|
|||||||
name: str
|
name: str
|
||||||
name_en: str = ""
|
name_en: str = ""
|
||||||
logo: str
|
logo: str
|
||||||
|
logo_en: str = ""
|
||||||
hero: str
|
hero: str
|
||||||
contact_bg: str
|
contact_bg: str
|
||||||
phone: str
|
phone: str
|
||||||
@@ -98,6 +99,15 @@ class ArticleOut(ORMModel):
|
|||||||
faqs_en: Optional[list[dict]] = None
|
faqs_en: Optional[list[dict]] = None
|
||||||
status: str
|
status: str
|
||||||
seo_keyphrase: str
|
seo_keyphrase: str
|
||||||
|
# نام نویسنده و توضیحات سئوی اختصاصی — سربرگ مقاله، تگ description و اسکیمای Article
|
||||||
|
author: str = ""
|
||||||
|
meta_description: str = ""
|
||||||
|
meta_description_en: str = ""
|
||||||
|
slug_en: str = ""
|
||||||
|
seo_keyphrase_en: str = ""
|
||||||
|
author_en: str = ""
|
||||||
|
tags: list[str] = []
|
||||||
|
tags_en: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
class TestimonialOut(ORMModel):
|
class TestimonialOut(ORMModel):
|
||||||
@@ -434,6 +444,7 @@ class BrandIn(BaseModel):
|
|||||||
name: str = ""
|
name: str = ""
|
||||||
name_en: str = ""
|
name_en: str = ""
|
||||||
logo: str = ""
|
logo: str = ""
|
||||||
|
logo_en: str = ""
|
||||||
hero: str = ""
|
hero: str = ""
|
||||||
contact_bg: str = ""
|
contact_bg: str = ""
|
||||||
phone: str = ""
|
phone: str = ""
|
||||||
@@ -517,6 +528,14 @@ class ArticleIn(BaseModel):
|
|||||||
faqs_en: list[dict] = []
|
faqs_en: list[dict] = []
|
||||||
status: Literal["draft", "published"] = "draft"
|
status: Literal["draft", "published"] = "draft"
|
||||||
seo_keyphrase: str = Field(default="", max_length=128)
|
seo_keyphrase: str = Field(default="", max_length=128)
|
||||||
|
seo_keyphrase_en: str = Field(default="", max_length=128)
|
||||||
|
slug_en: str = Field(default="", max_length=128)
|
||||||
|
author: str = Field(default="", max_length=128)
|
||||||
|
author_en: str = Field(default="", max_length=128)
|
||||||
|
tags: list[str] = []
|
||||||
|
tags_en: list[str] = []
|
||||||
|
meta_description: str = ""
|
||||||
|
meta_description_en: str = ""
|
||||||
_validate_body = field_validator("body")(validate_doc_body)
|
_validate_body = field_validator("body")(validate_doc_body)
|
||||||
_validate_body_en = field_validator("body_en")(validate_doc_body)
|
_validate_body_en = field_validator("body_en")(validate_doc_body)
|
||||||
|
|
||||||
@@ -823,6 +842,14 @@ class ArticlePatch(BaseModel):
|
|||||||
faqs_en: Optional[list[dict]] = None
|
faqs_en: Optional[list[dict]] = None
|
||||||
status: Optional[Literal["draft", "published"]] = None
|
status: Optional[Literal["draft", "published"]] = None
|
||||||
seo_keyphrase: Optional[str] = None
|
seo_keyphrase: Optional[str] = None
|
||||||
|
seo_keyphrase_en: Optional[str] = None
|
||||||
|
slug_en: Optional[str] = None
|
||||||
|
author: Optional[str] = None
|
||||||
|
author_en: Optional[str] = None
|
||||||
|
tags: Optional[list[str]] = None
|
||||||
|
tags_en: Optional[list[str]] = None
|
||||||
|
meta_description: Optional[str] = None
|
||||||
|
meta_description_en: Optional[str] = None
|
||||||
_validate_body = field_validator("body")(validate_doc_body)
|
_validate_body = field_validator("body")(validate_doc_body)
|
||||||
_validate_body_en = field_validator("body_en")(validate_doc_body)
|
_validate_body_en = field_validator("body_en")(validate_doc_body)
|
||||||
TestimonialPatch = _make_patch("TestimonialPatch", TestimonialIn)
|
TestimonialPatch = _make_patch("TestimonialPatch", TestimonialIn)
|
||||||
|
|||||||
+638
-161
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
|||||||
|
"""ستونهای چندزبانه مقاله — اسلاگ/کلیدواژه/نویسنده انگلیسی و تگهای دو زبانه.
|
||||||
|
|
||||||
|
فیلدهای «نویسنده» و «متا دیسکریپشن» در ادیتور تا پیش از این نسخه یا مشترک
|
||||||
|
بودند یا بیاثر؛ از این نسخه هر زبان ستون خودش را دارد.
|
||||||
|
|
||||||
|
Revision ID: 0002
|
||||||
|
Revises: 0001
|
||||||
|
Create Date: 2026-10-01
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0002"
|
||||||
|
down_revision: Union[str, None] = "0001"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
articles = sa.table("articles")
|
||||||
|
columns = sa.inspect(op.get_bind()).get_columns("articles")
|
||||||
|
existing = {c["name"] for c in columns}
|
||||||
|
if "slug_en" not in existing:
|
||||||
|
op.add_column("articles", sa.Column("slug_en", sa.String(128), nullable=False, server_default=""))
|
||||||
|
if "seo_keyphrase_en" not in existing:
|
||||||
|
op.add_column("articles", sa.Column("seo_keyphrase_en", sa.String(128), nullable=False, server_default=""))
|
||||||
|
if "author_en" not in existing:
|
||||||
|
op.add_column("articles", sa.Column("author_en", sa.String(128), nullable=False, server_default=""))
|
||||||
|
if "tags" not in existing:
|
||||||
|
op.add_column("articles", sa.Column("tags", sa.JSON(), nullable=False, server_default="[]"))
|
||||||
|
if "tags_en" not in existing:
|
||||||
|
op.add_column("articles", sa.Column("tags_en", sa.JSON(), nullable=False, server_default="[]"))
|
||||||
|
_ = articles
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("articles", "tags_en")
|
||||||
|
op.drop_column("articles", "tags")
|
||||||
|
op.drop_column("articles", "author_en")
|
||||||
|
op.drop_column("articles", "seo_keyphrase_en")
|
||||||
|
op.drop_column("articles", "slug_en")
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""لوگوی انگلیسی برند — ستون logo_en در جدول brand.
|
||||||
|
|
||||||
|
نسخهٔ انگلیسی سایت لوگوی اختصاصی خودش را دارد؛ فیلد مشترک logo برای هر دو
|
||||||
|
زبان کافی نبود. بازگشت به فارسی با خالیبودن ستون انجام میشود.
|
||||||
|
|
||||||
|
Revision ID: 0003
|
||||||
|
Revises: 0002
|
||||||
|
Create Date: 2026-10-01
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0003"
|
||||||
|
down_revision: Union[str, None] = "0002"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
cols = sa.inspect(op.get_bind()).get_columns("brand")
|
||||||
|
existing = {c["name"] for c in cols}
|
||||||
|
if "logo_en" not in existing:
|
||||||
|
op.add_column("brand", sa.Column("logo_en", sa.Text(), nullable=False, server_default=""))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
cols = sa.inspect(op.get_bind()).get_columns("brand")
|
||||||
|
if "logo_en" in {c["name"] for c in cols}:
|
||||||
|
op.drop_column("brand", "logo_en")
|
||||||
@@ -441,7 +441,7 @@ def test_upload_jpeg_converted_to_webp(auth_client, monkeypatch):
|
|||||||
assert body["width"] == 2000 and body["height"] == 1000
|
assert body["width"] == 2000 and body["height"] == 1000
|
||||||
|
|
||||||
|
|
||||||
def test_upload_animated_gif_becomes_animated_webp(auth_client, monkeypatch):
|
def test_upload_animated_gif_becomes_static_webp(auth_client, monkeypatch):
|
||||||
fake = _mock_storage(monkeypatch)
|
fake = _mock_storage(monkeypatch)
|
||||||
res = auth_client.post(
|
res = auth_client.post(
|
||||||
"/api/admin/upload",
|
"/api/admin/upload",
|
||||||
@@ -455,7 +455,8 @@ def test_upload_animated_gif_becomes_animated_webp(auth_client, monkeypatch):
|
|||||||
stored = next(iter(fake.objects.values()))
|
stored = next(iter(fake.objects.values()))
|
||||||
out = Image.open(io.BytesIO(stored))
|
out = Image.open(io.BytesIO(stored))
|
||||||
assert out.format == "WEBP"
|
assert out.format == "WEBP"
|
||||||
assert getattr(out, "n_frames", 1) == 2 # حرکت از بین نرفته
|
# هیچ آپلودی هرگز متحرک ذخیره نمیشود — پوستر فریم اول
|
||||||
|
assert getattr(out, "n_frames", 1) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_upload_undecodable_file_rejected(auth_client, monkeypatch):
|
def test_upload_undecodable_file_rejected(auth_client, monkeypatch):
|
||||||
@@ -467,14 +468,14 @@ def test_upload_undecodable_file_rejected(auth_client, monkeypatch):
|
|||||||
assert res.status_code == 415
|
assert res.status_code == 415
|
||||||
|
|
||||||
|
|
||||||
def test_upload_video_becomes_animated_webp(auth_client, monkeypatch):
|
def test_upload_video_becomes_static_webp_poster(auth_client, monkeypatch):
|
||||||
# ویدیو تنها با FFmpeg قابل پذیرش است — بدون آن ۴۱۵، با آن WebP متحرک
|
# ویدیو تنها با FFmpeg قابل پذیرش است — بدون آن ۴۱۵، با آن پوستر تکفریمی WebP
|
||||||
import shutil as _shutil
|
import shutil as _shutil
|
||||||
import subprocess as _subprocess
|
import subprocess as _subprocess
|
||||||
import tempfile as _tempfile
|
import tempfile as _tempfile
|
||||||
import os as _os
|
import os as _os
|
||||||
|
|
||||||
_mock_storage(monkeypatch)
|
fake = _mock_storage(monkeypatch)
|
||||||
if _shutil.which("ffmpeg") is None:
|
if _shutil.which("ffmpeg") is None:
|
||||||
res = auth_client.post(
|
res = auth_client.post(
|
||||||
"/api/admin/upload",
|
"/api/admin/upload",
|
||||||
@@ -499,6 +500,13 @@ def test_upload_video_becomes_animated_webp(auth_client, monkeypatch):
|
|||||||
)
|
)
|
||||||
assert res.status_code == 200, res.text
|
assert res.status_code == 200, res.text
|
||||||
assert res.json()["content_type"] == "image/webp"
|
assert res.json()["content_type"] == "image/webp"
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
|
stored = next(iter(fake.objects.values()))
|
||||||
|
out = Image.open(io.BytesIO(stored))
|
||||||
|
# ویدیو هرگز وب متحرک نمیشود — در کارتها سوسو نمیزند
|
||||||
|
assert getattr(out, "n_frames", 1) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_upload_without_name_uses_filename(auth_client, monkeypatch):
|
def test_upload_without_name_uses_filename(auth_client, monkeypatch):
|
||||||
|
|||||||
+30
-7
@@ -33,18 +33,27 @@
|
|||||||
└─ تولید پاسخ فارسی با LLM (قواعد تکیه بر داده + معرفی صفحه مرتبط + پیام جایگزین مشخص)
|
└─ تولید پاسخ فارسی با LLM (قواعد تکیه بر داده + معرفی صفحه مرتبط + پیام جایگزین مشخص)
|
||||||
```
|
```
|
||||||
|
|
||||||
ایندکس از محتوای PostgreSQL سایت ساخته میشود (`articles` منتشرشده، `projects`، `services`،
|
ایندکس از محتوای PostgreSQL سایت ساخته میشود (`articles` منتشرشده + پرسشوپاسخهای
|
||||||
`faqs`، `brand`، `offices`، `investment_models`، `process_steps`، `why_us`، `team`،
|
پایان مقاله، `projects` با مشخصات کامل، `services`، `faqs`، `brand`، `offices`،
|
||||||
`testimonials`، `timeline`، `story`). متنهای بلند به قطعات ~۷۰۰ نویسهای همپوشان برش میخورند.
|
`investment_models`، `process_steps`، `why_us`، `team`، `testimonials`، `timeline`،
|
||||||
|
`story`، و متنهای مدیریت محتوا: `stats`، `hero`، `page_heroes`، `section_headings`،
|
||||||
|
`footer`، `before_after`). سطرهای جایگاهخالی (متن زیر ~۳۰ نویسه) ایندکس نمیشوند.
|
||||||
|
متنهای بلند به قطعات ~۷۰۰ نویسهای همپوشان برش میخورند.
|
||||||
|
|
||||||
- **ایندکس برداری در pgvector**: جدول `chatbot_chunks` در دیتابیس اختصاصی
|
- **ایندکس برداری در pgvector**: جدول `chatbot_chunks` در دیتابیس اختصاصی
|
||||||
`aramesh_chatbot` (روی همان نمونه Postgres) با ستون `embedding vector(384)` و
|
`aramesh_chatbot` (روی همان نمونه Postgres) با ستون `embedding vector(384)` و
|
||||||
ایندکس HNSW کسینوسی؛ هر قطعه `doc_id` پایدار و `content_hash` دارد و همگامسازی
|
ایندکس HNSW کسینوسی؛ هر قطعه `doc_id` پایدار (شامل url: `type:url-key:occ:chunk`)
|
||||||
فقط سطرهای جدید/تغییریافته را امبد میکند. دیتابیس و جدول در شروع سرویس خودکار ساخته میشوند.
|
و `content_hash` دارد و همگامسازی فقط سطرهای جدید/تغییریافته را امبد میکند.
|
||||||
|
دیتابیس و جدول در شروع سرویس خودکار ساخته میشوند.
|
||||||
|
- **تیتر در متن قطعه**: در سندهای تکقطعهای که تیترشان در متن نیست (مثلاً نام پروژه)،
|
||||||
|
تیتر به ابتدای متن میچسبد تا BM25 و جستوجوی معنایی پیدایش کنند.
|
||||||
- **بازسازی خودکار**: یک حلقهٔ پسزمینه هر `INDEX_REFRESH_SECONDS` ثانیه محتوا را چک
|
- **بازسازی خودکار**: یک حلقهٔ پسزمینه هر `INDEX_REFRESH_SECONDS` ثانیه محتوا را چک
|
||||||
میکند و اگر ادمین چیزی را در پنل عوض کرده باشد، فقط تغییرات همگام میشود
|
میکند و اگر ادمین چیزی را در پنل عوض کرده باشد، فقط تغییرات همگام میشود
|
||||||
(امبد اجباری همه: `POST /chat/reindex`).
|
(امبد اجباری همه: `POST /chat/reindex`).
|
||||||
- **ماندگاری گفتگو**: در حافظهٔ همان پروسه (MemorySaver لنگگراف) — با ریاستارت پاک میشود.
|
- **ماندگاری گفتگو**: در حافظهٔ همان پروسه (MemorySaver لنگگراف) — با ریاستارت پاک میشود.
|
||||||
|
- **گزارش گفتگوها**: هر پاسخ (بههمراه تعداد سند بازیابیشده، تشخیص «پاسخی نبود» و تأخیر)
|
||||||
|
در جدول `chat_logs` همان دیتابیس ذخیره میشود تا پرسشهای بیپاسخ — یعنی شکافهای
|
||||||
|
دانش — قابل کشف باشند؛ `GET /chat/stats` جمعبندی میدهد.
|
||||||
|
|
||||||
## متغیرهای محیطی (در `.env` ریشه پروژه)
|
## متغیرهای محیطی (در `.env` ریشه پروژه)
|
||||||
|
|
||||||
@@ -75,15 +84,29 @@
|
|||||||
|
|
||||||
| مسیر | توضیح |
|
| مسیر | توضیح |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GET /chat/stream?message=…&thread_id=…` | استریم SSE توکنبهتوکن پاسخ (`{"content": …}` و در پایان `{"done": true}`) |
|
| `GET /chat/stream?message=…&thread_id=…` | استریم SSE توکنبهتوکن پاسخ (`{"content": …}` و در پایان `{"done": true, "fallback": true|false}`) |
|
||||||
| `GET /chat/suggestions` | ۴ پیشنهاد پرسش (کش ۱۰ دقیقه؛ جایگزین: خود سؤالات متداول) |
|
| `GET /chat/suggestions` | ۴ پیشنهاد پرسش (کش ۱۰ دقیقه؛ جایگزین: خود سؤالات متداول) |
|
||||||
| `GET /chat/eval?message=…` | مخصوص ارزیابی RAGAS — پاسخ + اسناد بازیابیشده با thread تازه |
|
| `GET /chat/eval?message=…` | مخصوص ارزیابی — پاسخ + اسناد بازیابیشده با thread تازه |
|
||||||
|
| `GET /chat/stats` | آمار گفتگوها (کل، ۷ روز اخیر، نرخ پیام جایگزین)؛ فهرست پرسشهای بیپاسخ اخیر فقط با هدر `X-Reindex-Token` |
|
||||||
| `GET /chat/healthz` | وضعیت ایندکس/LLM/تعداد اسناد |
|
| `GET /chat/healthz` | وضعیت ایندکس/LLM/تعداد اسناد |
|
||||||
| `POST /chat/reindex` | بازسازی اجباری ایندکس (هدر `X-Reindex-Token`) |
|
| `POST /chat/reindex` | بازسازی اجباری ایندکس (هدر `X-Reindex-Token`) |
|
||||||
|
|
||||||
مسیرهای `GET /chat/{scope}/stream|eval|suggestions` هم برای سازگاری با اسکریپت
|
مسیرهای `GET /chat/{scope}/stream|eval|suggestions` هم برای سازگاری با اسکریپت
|
||||||
`eval_ragas.py` قدیمی موجود است — کافی است `--url` را به نشانی این سرویس بدهید.
|
`eval_ragas.py` قدیمی موجود است — کافی است `--url` را به نشانی این سرویس بدهید.
|
||||||
|
|
||||||
|
## ارزیابی
|
||||||
|
|
||||||
|
مجموعه طلایی `eval_set.json` (پرسشهای واقعی از محتوای دیتابیس + یک کنترل مثبت برای
|
||||||
|
«نباید قیمت بسازد») با `eval_run.py` اجرا میشود — داخل کانتینر:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec aramesh-site-chatbot-1 python /srv/chatbot/eval_run.py --out /tmp/report.md
|
||||||
|
docker exec aramesh-site-chatbot-1 cat /tmp/report.md
|
||||||
|
```
|
||||||
|
|
||||||
|
خروجی: وضعیت هر پرسش (`pass` / `miss` / `manual` / `fallback-expected` / `unexpected-fallback`)،
|
||||||
|
منابع بازیابیشده و زمان پاسخ — برای سنجش اثر تغییرات دانش و کشف پسرفتها.
|
||||||
|
|
||||||
## اجرا
|
## اجرا
|
||||||
|
|
||||||
با داکر (بخشی از استک اصلی است — `docker compose up -d --build`):
|
با داکر (بخشی از استک اصلی است — `docker compose up -d --build`):
|
||||||
|
|||||||
+32
-1
@@ -130,14 +130,31 @@ async def _stream_answer(message: str, thread_id: str):
|
|||||||
|
|
||||||
config = {"configurable": {"thread_id": thread_id}}
|
config = {"configurable": {"thread_id": thread_id}}
|
||||||
inputs = {"messages": [HumanMessage(content=message)]}
|
inputs = {"messages": [HumanMessage(content=message)]}
|
||||||
|
started = time.time()
|
||||||
|
answer_parts: list[str] = []
|
||||||
try:
|
try:
|
||||||
async for event in pipeline.graph.astream_events(inputs, config=config, version="v2"):
|
async for event in pipeline.graph.astream_events(inputs, config=config, version="v2"):
|
||||||
# فقط توکنهای پاسخ نهایی — نه فراخوانیهای کمکی (بازنویسی/گسترش پرسش)
|
# فقط توکنهای پاسخ نهایی — نه فراخوانیهای کمکی (بازنویسی/گسترش پرسش)
|
||||||
if event["event"] == "on_chat_model_stream" and "generate" in (event.get("tags") or []):
|
if event["event"] == "on_chat_model_stream" and "generate" in (event.get("tags") or []):
|
||||||
text = _chunk_text(event["data"]["chunk"])
|
text = _chunk_text(event["data"]["chunk"])
|
||||||
if text:
|
if text:
|
||||||
|
answer_parts.append(text)
|
||||||
yield _sse({"content": text, "done": False})
|
yield _sse({"content": text, "done": False})
|
||||||
yield _sse({"content": "", "done": True})
|
|
||||||
|
# وضعیت نهایی گراف: تعداد اسناد بازیابیشده — برای تشخیص «پاسخی نبود»
|
||||||
|
n_docs = 0
|
||||||
|
try:
|
||||||
|
state = await pipeline.graph.aget_state(config)
|
||||||
|
n_docs = len(state.values.get("documents") or [])
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Final state fetch failed: %s", e)
|
||||||
|
answer = "".join(answer_parts)
|
||||||
|
is_fallback = n_docs == 0 or answer.strip() == pipeline.FALLBACK_MESSAGE
|
||||||
|
await asyncio.to_thread(
|
||||||
|
vectorstore.log_chat, thread_id, message, answer, n_docs, is_fallback,
|
||||||
|
int((time.time() - started) * 1000),
|
||||||
|
)
|
||||||
|
yield _sse({"content": "", "done": True, "fallback": is_fallback})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception("Streaming failed")
|
log.exception("Streaming failed")
|
||||||
yield _sse({"error": f"خطا در تولید پاسخ: {e}", "done": True})
|
yield _sse({"error": f"خطا در تولید پاسخ: {e}", "done": True})
|
||||||
@@ -189,6 +206,20 @@ async def eval_question(message: str = Query(...)):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/chat/stats")
|
||||||
|
async def chat_stats(request: Request):
|
||||||
|
"""آمار گفتگوها — جمعبندی برای همه؛ پرسشهای بیپاسخ اخیر فقط با توکن."""
|
||||||
|
stats = await asyncio.to_thread(vectorstore.chat_stats)
|
||||||
|
payload = {
|
||||||
|
"total_logged": stats["total_logged"],
|
||||||
|
"last_7d": stats["last_7d"],
|
||||||
|
"fallback_7d": stats["fallback_7d"],
|
||||||
|
}
|
||||||
|
if settings.reindex_token and request.headers.get("x-reindex-token") == settings.reindex_token:
|
||||||
|
payload["recent_fallback_questions"] = stats["recent_fallback_questions"]
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@api.post("/chat/reindex")
|
@api.post("/chat/reindex")
|
||||||
async def reindex(request: Request):
|
async def reindex(request: Request):
|
||||||
"""همگامسازی اجباری (امبد دوباره همه سطرها) — با توکن محافظت میشود."""
|
"""همگامسازی اجباری (امبد دوباره همه سطرها) — با توکن محافظت میشود."""
|
||||||
|
|||||||
+5
-1
@@ -1,5 +1,6 @@
|
|||||||
"""تنظیمات سرویس چتبات — از متغیرهای محیطی یا فایل .env خوانده میشود."""
|
"""تنظیمات سرویس چتبات — از متغیرهای محیطی یا فایل .env خوانده میشود."""
|
||||||
|
|
||||||
|
from pydantic import AliasChoices, Field
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
@@ -44,7 +45,10 @@ class Settings(BaseSettings):
|
|||||||
rate_limit_window: int = 300
|
rate_limit_window: int = 300
|
||||||
|
|
||||||
# توکن بازسازی ایندکس — اگر خالی باشد مسیر reindex فقط از داخل شبکه کار میکند
|
# توکن بازسازی ایندکس — اگر خالی باشد مسیر reindex فقط از داخل شبکه کار میکند
|
||||||
reindex_token: str = ""
|
# (.env از نام CHAT_REINDEX_TOKEN استفاده میکند — هر دو نام پذیرفته میشود)
|
||||||
|
reindex_token: str = Field(
|
||||||
|
default="", validation_alias=AliasChoices("REINDEX_TOKEN", "CHAT_REINDEX_TOKEN")
|
||||||
|
)
|
||||||
|
|
||||||
# ماندگاری کش پیشنهادها و زمینه سایت (ثانیه)
|
# ماندگاری کش پیشنهادها و زمینه سایت (ثانیه)
|
||||||
suggestions_ttl: int = 600
|
suggestions_ttl: int = 600
|
||||||
|
|||||||
+173
-15
@@ -2,7 +2,12 @@
|
|||||||
|
|
||||||
هر سند یک dict با کلیدهای text / type / title / url است. بدنه مقالهها و
|
هر سند یک dict با کلیدهای text / type / title / url است. بدنه مقالهها و
|
||||||
پروژهها هم میتواند آرایه پاراگراف باشد و هم سند TipTap (JSON) — هر دو
|
پروژهها هم میتواند آرایه پاراگراف باشد و هم سند TipTap (JSON) — هر دو
|
||||||
پشتیبانی میشوند. مقالههای پیشنویس ایندکس نمیشوند.
|
پشتیبانی میشوند. مقالههای پیشنویس ایندکس نمیشوند؛ اما پرسشوپاسخهای
|
||||||
|
پایان مقالههای منتشرشده بهعنوان سند faq ایندکس میشوند.
|
||||||
|
|
||||||
|
بهجز جدولهای محتوایی، متنهای مدیریت محتوا (آمار، هیرو، هیرو صفحهها،
|
||||||
|
تیتر بخشها، فوتر، قبل/بعد) هم ایندکس میشوند — ادعاهای مرکزی سایت
|
||||||
|
(سال تجربه، تعداد پروژه و…) فقط آنجا ثبت شدهاند.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -16,6 +21,19 @@ log = logging.getLogger(__name__)
|
|||||||
_ARTICLE_URL = "/blog/{slug}"
|
_ARTICLE_URL = "/blog/{slug}"
|
||||||
_PROJECT_URL = "/projects/{slug}"
|
_PROJECT_URL = "/projects/{slug}"
|
||||||
|
|
||||||
|
# بخشهای صفحه اصلی و صفحات داخلی — برای نسبت دادن URL به تیتر بخشها
|
||||||
|
_SECTION_URL = {
|
||||||
|
"services": "/services",
|
||||||
|
"featured_projects": "/projects",
|
||||||
|
"articles": "/blog",
|
||||||
|
"faq": "/faq",
|
||||||
|
"investment_models": "/investment",
|
||||||
|
"process": "/investment",
|
||||||
|
}
|
||||||
|
|
||||||
|
# سندهای کوتاهتر از این جایگاهخالیاند («خدمت جدید» و امثال آن) — ایندکس نمیشوند
|
||||||
|
_MIN_DOC_CHARS = 30
|
||||||
|
|
||||||
|
|
||||||
def _psycopg_dsn(database_url: str) -> str:
|
def _psycopg_dsn(database_url: str) -> str:
|
||||||
"""DATABASE_URL فرمت SQLAlchemy (postgresql+psycopg://) را به DSN psycopg3 تبدیل میکند."""
|
"""DATABASE_URL فرمت SQLAlchemy (postgresql+psycopg://) را به DSN psycopg3 تبدیل میکند."""
|
||||||
@@ -75,7 +93,9 @@ def body_to_text(body: Any) -> str:
|
|||||||
|
|
||||||
def _doc(text: str, dtype: str, title: str, url: str) -> dict | None:
|
def _doc(text: str, dtype: str, title: str, url: str) -> dict | None:
|
||||||
text = (text or "").strip()
|
text = (text or "").strip()
|
||||||
if not text:
|
if len(text) < _MIN_DOC_CHARS:
|
||||||
|
if text:
|
||||||
|
log.info("Skipped tiny doc (%d chars, type=%s, title=%s)", len(text), dtype, title)
|
||||||
return None
|
return None
|
||||||
return {"text": text, "type": dtype, "title": title, "url": url}
|
return {"text": text, "type": dtype, "title": title, "url": url}
|
||||||
|
|
||||||
@@ -84,6 +104,19 @@ def _clean(value: Any) -> str:
|
|||||||
return normalize(str(value or "")).strip()
|
return normalize(str(value or "")).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _format_stat_rows(rows: list[dict[str, Any]]) -> list[str]:
|
||||||
|
"""سطرهای جدول stats → «۲۳+ سال تجربه ساخت» (پسوند تکحرفی میچسبد، بقیه با فاصله)."""
|
||||||
|
parts: list[str] = []
|
||||||
|
for r in rows:
|
||||||
|
suffix = _clean(r.get("suffix"))
|
||||||
|
value = str(r.get("value") or "").strip()
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
num = f"{value}{suffix}" if len(suffix) == 1 else f"{value} {suffix}".strip()
|
||||||
|
parts.append(f"{num} {_clean(r.get('label'))}".strip())
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
def fetch_site_documents(database_url: str) -> list[dict]:
|
def fetch_site_documents(database_url: str) -> list[dict]:
|
||||||
"""همه محتوای عمومی سایت را به سند تبدیل میکند (بدون وابستگی به ORM بکاند)."""
|
"""همه محتوای عمومی سایت را به سند تبدیل میکند (بدون وابستگی به ORM بکاند)."""
|
||||||
dsn = _psycopg_dsn(database_url)
|
dsn = _psycopg_dsn(database_url)
|
||||||
@@ -105,6 +138,36 @@ def fetch_site_documents(database_url: str) -> list[dict]:
|
|||||||
if d:
|
if d:
|
||||||
docs.append(d)
|
docs.append(d)
|
||||||
|
|
||||||
|
# --- آمار و افتخارات — اعداد مرکزی سایت فقط اینجا ثبتاند ---
|
||||||
|
stats = _format_stat_rows(_fetch_rows(dsn, "SELECT value, suffix, label FROM stats ORDER BY position"))
|
||||||
|
if stats:
|
||||||
|
d = _doc("آمار و افتخارات مجموعه: " + "؛ ".join(stats), "stats", "آمار و افتخارات", "/")
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
|
# --- هیرو صفحه اصلی (رکورد یگانه) ---
|
||||||
|
hero_rows = _fetch_rows(
|
||||||
|
dsn,
|
||||||
|
"SELECT kicker, title, title_highlight, subtitle, badge_value, badge_label FROM hero LIMIT 1",
|
||||||
|
)
|
||||||
|
if hero_rows:
|
||||||
|
h = hero_rows[0]
|
||||||
|
title = _clean(h["title"])
|
||||||
|
if _clean(h["title_highlight"]):
|
||||||
|
title = f"{title} (کلمهٔ برجسته: {_clean(h['title_highlight'])})"
|
||||||
|
lines = list(filter(None, [
|
||||||
|
f"شعار بالای تیتر: {_clean(h['kicker'])}" if _clean(h["kicker"]) else "",
|
||||||
|
f"تیتر اصلی: {title}" if title else "",
|
||||||
|
f"زیرتیتر: {_clean(h['subtitle'])}" if _clean(h["subtitle"]) else "",
|
||||||
|
(
|
||||||
|
f"نشان: {_clean(h['badge_value'])} {_clean(h['badge_label'])}"
|
||||||
|
if _clean(h["badge_value"]) or _clean(h["badge_label"]) else ""
|
||||||
|
),
|
||||||
|
]))
|
||||||
|
d = _doc("\n".join(lines), "hero", "معرفی صفحه اصلی", "/")
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
# --- دفاتر ---
|
# --- دفاتر ---
|
||||||
for row in _fetch_rows(dsn, "SELECT city, address, hours FROM offices ORDER BY position"):
|
for row in _fetch_rows(dsn, "SELECT city, address, hours FROM offices ORDER BY position"):
|
||||||
d = _doc(
|
d = _doc(
|
||||||
@@ -132,29 +195,50 @@ def fetch_site_documents(database_url: str) -> list[dict]:
|
|||||||
if d:
|
if d:
|
||||||
docs.append(d)
|
docs.append(d)
|
||||||
|
|
||||||
# --- پروژهها ---
|
# --- پروژهها (شامل پروژههای نقشهٔ صفحه پروژهها — فقط مشخصات، بدون توضیح) ---
|
||||||
for row in _fetch_rows(
|
for row in _fetch_rows(
|
||||||
dsn,
|
dsn,
|
||||||
"SELECT slug, title, location, year, status, area, floors, units, client, summary, body FROM projects ORDER BY position",
|
"""
|
||||||
|
SELECT slug, title, location, year, status, area, floors, land_area, pilot_floors,
|
||||||
|
units_residential, units_commercial, units_office, client,
|
||||||
|
project_type, usage, summary, body
|
||||||
|
FROM projects ORDER BY position
|
||||||
|
""",
|
||||||
):
|
):
|
||||||
|
units = "، ".join(
|
||||||
|
f"{v} واحد {label}"
|
||||||
|
for label, col in (("مسکونی", row["units_residential"]),
|
||||||
|
("تجاری", row["units_commercial"]),
|
||||||
|
("اداری", row["units_office"]))
|
||||||
|
if (v := _clean(col)) and v != "0"
|
||||||
|
)
|
||||||
meta = "\n".join(filter(None, [
|
meta = "\n".join(filter(None, [
|
||||||
f"مکان: {_clean(row['location'])}" if row["location"] else "",
|
f"مکان: {_clean(row['location'])}" if row["location"] else "",
|
||||||
f"سال: {_clean(row['year'])}" if row["year"] else "",
|
f"سال: {_clean(row['year'])}" if row["year"] else "",
|
||||||
f"وضعیت: {'در حال ساخت' if row['status'] == 'progress' else 'تکمیلشده'}",
|
f"وضعیت: {'در حال ساخت' if row['status'] == 'progress' else 'تکمیلشده'}",
|
||||||
f"متراژ: {_clean(row['area'])}" if row["area"] else "",
|
f"نوع پروژه: {_clean(row['project_type'])}" if row["project_type"] else "",
|
||||||
|
f"کاربری: {_clean(row['usage'])}" if row["usage"] else "",
|
||||||
|
f"متراژ زیربنا: {_clean(row['area'])}" if row["area"] else "",
|
||||||
|
f"عرصه: {_clean(row['land_area'])}" if row["land_area"] else "",
|
||||||
f"طبقات: {_clean(row['floors'])}" if row["floors"] else "",
|
f"طبقات: {_clean(row['floors'])}" if row["floors"] else "",
|
||||||
f"واحدها: {_clean(row['units'])}" if row["units"] else "",
|
f"طبقات پیلوت: {_clean(row['pilot_floors'])}" if row["pilot_floors"] else "",
|
||||||
|
f"واحدها: {units}" if units else "",
|
||||||
f"کارفرما: {_clean(row['client'])}" if row["client"] else "",
|
f"کارفرما: {_clean(row['client'])}" if row["client"] else "",
|
||||||
]))
|
]))
|
||||||
text = "\n".join(filter(None, [meta, _clean(row["summary"]), body_to_text(row["body"])]))
|
text = "\n".join(filter(None, [
|
||||||
|
f"پروژه «{_clean(row['title'])}»",
|
||||||
|
meta,
|
||||||
|
_clean(row["summary"]),
|
||||||
|
body_to_text(row["body"]),
|
||||||
|
]))
|
||||||
d = _doc(text, "project", _clean(row["title"]), _PROJECT_URL.format(slug=row["slug"]))
|
d = _doc(text, "project", _clean(row["title"]), _PROJECT_URL.format(slug=row["slug"]))
|
||||||
if d:
|
if d:
|
||||||
docs.append(d)
|
docs.append(d)
|
||||||
|
|
||||||
# --- مقالهها (فقط منتشرشده) ---
|
# --- مقالهها (فقط منتشرشده) + پرسشوپاسخهای پایان مقاله ---
|
||||||
for row in _fetch_rows(
|
for row in _fetch_rows(
|
||||||
dsn,
|
dsn,
|
||||||
"SELECT slug, title, date, category, excerpt, body FROM articles WHERE status = 'published' ORDER BY position",
|
"SELECT slug, title, date, category, excerpt, body, faqs FROM articles WHERE status = 'published' ORDER BY position",
|
||||||
):
|
):
|
||||||
text = "\n".join(filter(None, [
|
text = "\n".join(filter(None, [
|
||||||
f"دستهبندی: {_clean(row['category'])}" if row["category"] else "",
|
f"دستهبندی: {_clean(row['category'])}" if row["category"] else "",
|
||||||
@@ -165,6 +249,15 @@ def fetch_site_documents(database_url: str) -> list[dict]:
|
|||||||
if d:
|
if d:
|
||||||
docs.append(d)
|
docs.append(d)
|
||||||
|
|
||||||
|
article_url = _ARTICLE_URL.format(slug=row["slug"])
|
||||||
|
for f in row["faqs"] or []:
|
||||||
|
if not isinstance(f, dict):
|
||||||
|
continue
|
||||||
|
q, a = _clean(f.get("question")), _clean(f.get("answer"))
|
||||||
|
d = _doc(f"پرسش: {q}\nپاسخ: {a}", "faq", q, article_url)
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
# --- مدلهای سرمایهگذاری ---
|
# --- مدلهای سرمایهگذاری ---
|
||||||
for row in _fetch_rows(dsn, 'SELECT title, "desc", points FROM investment_models ORDER BY position'):
|
for row in _fetch_rows(dsn, 'SELECT title, "desc", points FROM investment_models ORDER BY position'):
|
||||||
points = "؛ ".join(_clean(p) for p in (row["points"] or []))
|
points = "؛ ".join(_clean(p) for p in (row["points"] or []))
|
||||||
@@ -187,7 +280,29 @@ def fetch_site_documents(database_url: str) -> list[dict]:
|
|||||||
if d:
|
if d:
|
||||||
docs.append(d)
|
docs.append(d)
|
||||||
|
|
||||||
# --- چرا ما / تیم / نظرسنجی / تایملاین / داستان ما ---
|
# --- تیتر و توضیح بخشها (مدیریت محتوا) ---
|
||||||
|
for row in _fetch_rows(dsn, "SELECT key, title, lead FROM section_headings ORDER BY key"):
|
||||||
|
text = "\n".join(filter(None, [_clean(row["title"]), _clean(row["lead"])]))
|
||||||
|
d = _doc(text, "section", _clean(row["title"]), _SECTION_URL.get(row["key"], "/"))
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
|
# --- هیرو صفحههای داخلی (about/services/projects/… ) ---
|
||||||
|
for row in _fetch_rows(dsn, "SELECT key, title, lead FROM page_heroes ORDER BY key"):
|
||||||
|
text = "\n".join(filter(None, [_clean(row["title"]), _clean(row["lead"])]))
|
||||||
|
d = _doc(text, "page_hero", _clean(row["title"]), f"/{row['key']}")
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
|
# --- قبل/بعد (بخش صفحه اصلی) ---
|
||||||
|
ba_rows = _fetch_rows(dsn, "SELECT title, lead FROM before_after LIMIT 1")
|
||||||
|
if ba_rows:
|
||||||
|
text = "\n".join(filter(None, [_clean(ba_rows[0]["title"]), _clean(ba_rows[0]["lead"])]))
|
||||||
|
d = _doc(text, "before_after", _clean(ba_rows[0]["title"]) or "قبل و بعد", "/")
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
|
# --- چرا ما / تیم / نظرسنجی / تایملاین / داستان ما / فوتر ---
|
||||||
for row in _fetch_rows(dsn, 'SELECT title, "desc" FROM why_us ORDER BY position'):
|
for row in _fetch_rows(dsn, 'SELECT title, "desc" FROM why_us ORDER BY position'):
|
||||||
d = _doc(f"{_clean(row['title'])}: {_clean(row['desc'])}", "about", _clean(row["title"]), "/about")
|
d = _doc(f"{_clean(row['title'])}: {_clean(row['desc'])}", "about", _clean(row["title"]), "/about")
|
||||||
if d:
|
if d:
|
||||||
@@ -201,13 +316,24 @@ def fetch_site_documents(database_url: str) -> list[dict]:
|
|||||||
if d:
|
if d:
|
||||||
docs.append(d)
|
docs.append(d)
|
||||||
|
|
||||||
for row in _fetch_rows(dsn, "SELECT name, role, quote FROM testimonials ORDER BY position"):
|
testimonial_rows = _fetch_rows(dsn, "SELECT name, role, quote FROM testimonials ORDER BY position")
|
||||||
|
for row in testimonial_rows:
|
||||||
d = _doc(
|
d = _doc(
|
||||||
f"نظر {_clean(row['name'])} ({_clean(row['role'])}): {_clean(row['quote'])}",
|
f"نظر {_clean(row['name'])} ({_clean(row['role'])}): {_clean(row['quote'])}",
|
||||||
"testimonial", _clean(row["name"]), "/",
|
"testimonial", _clean(row["name"]), "/",
|
||||||
)
|
)
|
||||||
if d:
|
if d:
|
||||||
docs.append(d)
|
docs.append(d)
|
||||||
|
# سند تجمیعی — پرسشهای طبیعی مثل «نظرات مشتریان چیست؟» به تکنفرها نمیرسند
|
||||||
|
# (بیریشهسازی BM25: «نظرات» با «نظر» و «مشتریان» با «کارفرما» هیچ اشتراکی ندارند)
|
||||||
|
if len(testimonial_rows) >= 2:
|
||||||
|
agg = "؛ ".join(
|
||||||
|
f"{_clean(t['name'])} ({_clean(t['role'])}): {_clean(t['quote'])}"
|
||||||
|
for t in testimonial_rows
|
||||||
|
)
|
||||||
|
d = _doc(f"نظرات مشتریان و کارفرمایان پروژهها:\n{agg}", "testimonial", "نظرات مشتریان", "/")
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
for row in _fetch_rows(dsn, 'SELECT year, title, "desc" FROM timeline ORDER BY position'):
|
for row in _fetch_rows(dsn, 'SELECT year, title, "desc" FROM timeline ORDER BY position'):
|
||||||
d = _doc(
|
d = _doc(
|
||||||
@@ -226,15 +352,31 @@ def fetch_site_documents(database_url: str) -> list[dict]:
|
|||||||
if d:
|
if d:
|
||||||
docs.append(d)
|
docs.append(d)
|
||||||
|
|
||||||
|
footer_rows = _fetch_rows(dsn, "SELECT about_text FROM footer LIMIT 1")
|
||||||
|
if footer_rows and _clean(footer_rows[0]["about_text"]):
|
||||||
|
d = _doc(
|
||||||
|
_clean(footer_rows[0]["about_text"]),
|
||||||
|
"about", "درباره مجموعه (فوتر)", "/",
|
||||||
|
)
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
log.info("Fetched %d documents from site database", len(docs))
|
log.info("Fetched %d documents from site database", len(docs))
|
||||||
return docs
|
return docs
|
||||||
|
|
||||||
|
|
||||||
|
def _url_key(url: str) -> str:
|
||||||
|
"""URL → کلید یکتا برای doc_id («/projects/x» → «projects-x»)."""
|
||||||
|
return url.strip("/").replace("/", "-") or "home"
|
||||||
|
|
||||||
|
|
||||||
def chunk_documents(docs: list[dict], chunk_size: int = 700, overlap: int = 120) -> list[dict]:
|
def chunk_documents(docs: list[dict], chunk_size: int = 700, overlap: int = 120) -> list[dict]:
|
||||||
"""متنهای بلند (مقاله/پروژه) را به قطعات همپوشان تقسیم میکند.
|
"""متنهای بلند (مقاله/پروژه) را به قطعات همپوشان تقسیم میکند.
|
||||||
|
|
||||||
هر قطعه یک doc_id پایدار میگیرد («type:ترتیب در همان url:شماره قطعه») تا
|
هر قطعه یک doc_id پایدار میگیرد («type:url-key:ترتیب در همان url:شماره قطعه») تا
|
||||||
همگامسازی افزایشی pgvector بتواند فقط سطرهای تغییر یافته را امبد کند.
|
همگامسازی افزایشی pgvector بتواند فقط سطرهای تغییر یافته را امبد کند.
|
||||||
|
URL بخشی از doc_id است — بدون آن همه پروژهها/مقالهها که url یکتا دارند
|
||||||
|
روی همان id میافتند و در جدول بردار یکدیگر را بازنویسی میکنند.
|
||||||
"""
|
"""
|
||||||
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
||||||
|
|
||||||
@@ -257,9 +399,15 @@ def chunk_documents(docs: list[dict], chunk_size: int = 700, overlap: int = 120)
|
|||||||
|
|
||||||
pieces = splitter.split_text(doc["text"]) if len(doc["text"]) > chunk_size * 1.3 else [doc["text"]]
|
pieces = splitter.split_text(doc["text"]) if len(doc["text"]) > chunk_size * 1.3 else [doc["text"]]
|
||||||
for i, piece in enumerate(pieces, 1):
|
for i, piece in enumerate(pieces, 1):
|
||||||
|
if len(pieces) == 1:
|
||||||
|
# تیتر باید در متن امبدشده باشد تا BM25/معنایی پیدایش کند —
|
||||||
|
# در سندهای تکقطعهای که تیترشان در متن نیست، به ابتدای قطعه میچسبد
|
||||||
|
text = piece if not doc["title"] or doc["title"] in piece[:150] else f"{doc['title']}\n{piece}"
|
||||||
|
else:
|
||||||
|
text = f"[{doc['title']} — بخش {i} از {len(pieces)}]\n{piece}"
|
||||||
chunk = {
|
chunk = {
|
||||||
"doc_id": f"{doc['type']}:{occ}:{i}",
|
"doc_id": f"{doc['type']}:{_url_key(doc['url'])}:{occ}:{i}",
|
||||||
"text": piece if len(pieces) == 1 else f"[{doc['title']} — بخش {i} از {len(pieces)}]\n{piece}",
|
"text": text,
|
||||||
"type": doc["type"],
|
"type": doc["type"],
|
||||||
"title": doc["title"],
|
"title": doc["title"],
|
||||||
"url": doc["url"],
|
"url": doc["url"],
|
||||||
@@ -285,7 +433,7 @@ _site_context_cache: tuple[float, str] | None = None
|
|||||||
|
|
||||||
|
|
||||||
def fetch_site_context(database_url: str, ttl: int = 120) -> str:
|
def fetch_site_context(database_url: str, ttl: int = 120) -> str:
|
||||||
"""نام برند، تماس و فهرست خدمات — با کش کوتاهمدت تا هر پیام یک کوئری سبک بزند."""
|
"""نام برند، تماس، خدمات، آمار و دفاتر — با کش کوتاهمدت تا هر پیام یک کوئری سبک بزند."""
|
||||||
global _site_context_cache
|
global _site_context_cache
|
||||||
import time as _time
|
import time as _time
|
||||||
|
|
||||||
@@ -296,6 +444,10 @@ def fetch_site_context(database_url: str, ttl: int = 120) -> str:
|
|||||||
dsn = _psycopg_dsn(database_url)
|
dsn = _psycopg_dsn(database_url)
|
||||||
brand = _fetch_rows(dsn, "SELECT name, phone, email, address FROM brand LIMIT 1")
|
brand = _fetch_rows(dsn, "SELECT name, phone, email, address FROM brand LIMIT 1")
|
||||||
services = _fetch_rows(dsn, "SELECT title FROM services ORDER BY position")
|
services = _fetch_rows(dsn, "SELECT title FROM services ORDER BY position")
|
||||||
|
stats = _format_stat_rows(
|
||||||
|
_fetch_rows(dsn, "SELECT value, suffix, label FROM stats ORDER BY position")
|
||||||
|
)
|
||||||
|
offices = _fetch_rows(dsn, "SELECT city, address FROM offices ORDER BY position")
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
if brand:
|
if brand:
|
||||||
b = brand[0]
|
b = brand[0]
|
||||||
@@ -307,6 +459,12 @@ def fetch_site_context(database_url: str, ttl: int = 120) -> str:
|
|||||||
]
|
]
|
||||||
if services:
|
if services:
|
||||||
lines.append("خدمات سایت: " + "، ".join(_clean(s["title"]) for s in services))
|
lines.append("خدمات سایت: " + "، ".join(_clean(s["title"]) for s in services))
|
||||||
|
if stats:
|
||||||
|
lines.append("آمار: " + "؛ ".join(stats))
|
||||||
|
if offices:
|
||||||
|
lines.append("دفاتر: " + "؛ ".join(
|
||||||
|
f"{_clean(o['city'])} ({_clean(o['address'])})" for o in offices
|
||||||
|
))
|
||||||
ctx = "\n".join(lines)
|
ctx = "\n".join(lines)
|
||||||
_site_context_cache = (now, ctx)
|
_site_context_cache = (now, ctx)
|
||||||
return ctx
|
return ctx
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""اجرای مجموعه طلایی روی سرویس چتبات و ساخت گزارش مارکداون.
|
||||||
|
|
||||||
|
استفاده (داخل کانتینر یا هر جا که به سرویس دسترسی هست):
|
||||||
|
python eval_run.py --base http://localhost:8000
|
||||||
|
python eval_run.py --out eval-report.md
|
||||||
|
|
||||||
|
هر پرسش از /chat/eval سرویس میشود؛ پاسخ با زیررشتههای انتظار
|
||||||
|
(پس از نرمالسازی فارسی) مقایسه و رتبهبندی میشود:
|
||||||
|
pass — همه/یکی از انتظارها برآورده شد
|
||||||
|
miss — انتظار برآورده نشد (پاسخ احتمالاً نادرست یا بیربط)
|
||||||
|
manual — انتظار عددی تعریف نشده؛ بازبینی دستی
|
||||||
|
fallback-expected / unexpected-fallback — پیام جایگزین
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
FALLBACK_MESSAGE = (
|
||||||
|
"متأسفانه اطلاعات دقیقی در این باره در سایت ثبت نشده است. "
|
||||||
|
"برای راهنمایی بیشتر میتوانید از صفحه «تماس با ما» با ما در ارتباط باشید."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ask(base: str, question: str, timeout: int = 180) -> dict:
|
||||||
|
url = f"{base}/chat/eval?{urllib.parse.urlencode({'message': question})}"
|
||||||
|
with urllib.request.urlopen(url, timeout=timeout) as r:
|
||||||
|
return json.load(r)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="اجرای مجموعه طلایی ارزیابی چتبات")
|
||||||
|
ap.add_argument("--base", default="http://localhost:8000", help="نشانی سرویس چتبات")
|
||||||
|
ap.add_argument("--set", dest="eval_set",
|
||||||
|
default=str(Path(__file__).with_name("eval_set.json")))
|
||||||
|
ap.add_argument("--out", default="-", help="فایل خروجی گزارش (- = stdout)")
|
||||||
|
ap.add_argument("--sleep", type=float, default=0.5, help="مکث بین پرسشها (ثانیه)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from chatbot.persian import normalize
|
||||||
|
|
||||||
|
data = json.loads(Path(args.eval_set).read_text(encoding="utf-8"))
|
||||||
|
rows: list[dict] = []
|
||||||
|
|
||||||
|
for item in data["questions"]:
|
||||||
|
q = item["q"]
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
res = ask(args.base, q)
|
||||||
|
except Exception as e:
|
||||||
|
rows.append({**item, "answer": f"ERROR: {e}", "sources": [], "seconds": 0.0,
|
||||||
|
"status": "error", "fallback": False})
|
||||||
|
print(f" error: {q[:50]}", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
seconds = time.time() - t0
|
||||||
|
answer = str(res.get("answer", ""))
|
||||||
|
contexts = res.get("contexts") or []
|
||||||
|
is_fallback = answer.strip() == FALLBACK_MESSAGE or not contexts
|
||||||
|
|
||||||
|
if item.get("expect_fallback"):
|
||||||
|
status = "fallback-expected" if is_fallback else "unexpected-answer"
|
||||||
|
elif is_fallback:
|
||||||
|
status = "unexpected-fallback"
|
||||||
|
else:
|
||||||
|
needles = [normalize(s) for s in item.get("expect_any", [])]
|
||||||
|
if not needles:
|
||||||
|
status = "manual"
|
||||||
|
elif any(n in normalize(answer) for n in needles):
|
||||||
|
status = "pass"
|
||||||
|
else:
|
||||||
|
status = "miss"
|
||||||
|
|
||||||
|
rows.append({**item, "answer": answer, "sources": contexts, "seconds": seconds,
|
||||||
|
"status": status, "fallback": is_fallback})
|
||||||
|
print(f" [{status}] {q[:50]}", file=sys.stderr)
|
||||||
|
|
||||||
|
time.sleep(args.sleep)
|
||||||
|
|
||||||
|
# --- گزارش ---
|
||||||
|
lines = [f"# گزارش ارزیابی چتبات — {time.strftime('%Y-%m-%d %H:%M')}", ""]
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for r in rows:
|
||||||
|
counts[r["status"]] = counts.get(r["status"], 0) + 1
|
||||||
|
graded = [r for r in rows if r["status"] in ("pass", "miss", "unexpected-fallback", "unexpected-answer")]
|
||||||
|
ok = sum(1 for r in rows if r["status"] in ("pass", "fallback-expected"))
|
||||||
|
lines += [
|
||||||
|
f"- مجموع پرسشها: {len(rows)} — درست: {ok} — خطا: {counts.get('miss', 0) + counts.get('unexpected-fallback', 0) + counts.get('unexpected-answer', 0)}",
|
||||||
|
f"- بازبینی دستی: {counts.get('manual', 0)} — خطای فنی: {counts.get('error', 0)}",
|
||||||
|
f"- میانگین زمان پاسخ: {sum(r['seconds'] for r in rows) / max(len(rows), 1):.1f}s",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for r in rows:
|
||||||
|
srcs = [c.rsplit("(منبع:", 1)[-1].strip(" ()\n") for c in r["sources"]] if r["sources"] else []
|
||||||
|
lines += [
|
||||||
|
f"## [{r['status']}] {r['q']}",
|
||||||
|
f"*موضوع: {r.get('topic', '?')} — {r['seconds']:.1f}s — منابع: {'، '.join(srcs) or '—'}*",
|
||||||
|
"",
|
||||||
|
r["answer"],
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
report = "\n".join(lines)
|
||||||
|
if args.out == "-":
|
||||||
|
print(report)
|
||||||
|
else:
|
||||||
|
Path(args.out).write_text(report, encoding="utf-8")
|
||||||
|
print(f"Report written to {args.out}", file=sys.stderr)
|
||||||
|
|
||||||
|
return 1 if any(r["status"] in ("miss", "unexpected-answer") for r in rows) else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"description": "مجموعه طلایی ارزیابی چتبات — پرسشها از واقعیتهای زنده دیتابیس سایت ساخته شدهاند. expect_any: حداقل یکی از این زیررشتهها (پس از نرمالسازی) در پاسخ باشد؛ expect_fallback: انتظار پیام جایگزین (شکاف دانش). رشتههای خالی = بررسی دستی.",
|
||||||
|
"questions": [
|
||||||
|
{"q": "پروژه میرزا طاهر ۵ کجا ساخته شده و چند متر زیربنا دارد؟", "topic": "projects", "expect_any": ["8100"]},
|
||||||
|
{"q": "پروژه آرامش - بابک کی و کجا تحویل شده؟", "topic": "projects", "expect_any": ["1405", "6200"]},
|
||||||
|
{"q": "وضعیت پروژه میرزاطاهر ۶ چطور است؟", "topic": "projects", "expect_any": ["در حال ساخت"]},
|
||||||
|
{"q": "کدام پروژه شما در محله گورتان اصفهان است؟", "topic": "projects", "expect_any": ["وحدت"]},
|
||||||
|
{"q": "پروژه میرزا طاهر ۵ چند واحد مسکونی دارد؟", "topic": "projects", "expect_any": ["29"]},
|
||||||
|
{"q": "گروه ساختمانی آرامش چند سال سابقه دارد؟", "topic": "stats", "expect_any": ["23", "1381"]},
|
||||||
|
{"q": "تا امروز چند پروژه تحویل دادهاید؟", "topic": "stats", "expect_any": ["64"]},
|
||||||
|
{"q": "مجموع متراژ ساختوساز شما چقدر است؟", "topic": "stats", "expect_any": ["111"]},
|
||||||
|
{"q": "چند خانواده در پروژههای شما ساکن هستند؟", "topic": "stats", "expect_any": ["500"]},
|
||||||
|
{"q": "شماره تماس شما چیست؟", "topic": "contact", "expect_any": ["09133107809"]},
|
||||||
|
{"q": "دفتر تهران کجاست؟", "topic": "offices", "expect_any": ["ولیعصر"]},
|
||||||
|
{"q": "آدرس ایمیل مجموعه چیست؟", "topic": "contact", "expect_any": ["arameshsakhtemani"]},
|
||||||
|
{"q": "دفاتر شما در چه شهرهایی است؟", "topic": "offices", "expect_any": ["نوشهر"]},
|
||||||
|
{"q": "گروه آرامش چه سالی تأسیس شده؟", "topic": "faqs", "expect_any": ["1381"]},
|
||||||
|
{"q": "زمین دارم، ساخت را از کجا شروع کنم؟", "topic": "faqs", "expect_any": []},
|
||||||
|
{"q": "شرایط مشارکت در ساخت چگونه است؟", "topic": "investment", "expect_any": []},
|
||||||
|
{"q": "مدل صندوق سرمایهگذاری چطور کار میکند؟", "topic": "investment", "expect_any": ["24"]},
|
||||||
|
{"q": "پیشخرید واحد یعنی چه؟", "topic": "investment", "expect_any": ["پرداخت", "شخصیسازی", "تحویل"]},
|
||||||
|
{"q": "خدمات گروه آرامش چیست؟", "topic": "services", "expect_any": []},
|
||||||
|
{"q": "نظرات مشتریان قبلی درباره شما چیست؟", "topic": "testimonials", "expect_any": []},
|
||||||
|
{"q": "هزینه ساخت هر متر مربع چقدر است؟", "topic": "canary", "expect_fallback": true}
|
||||||
|
]
|
||||||
|
}
|
||||||
+14
-2
@@ -19,10 +19,22 @@ ids = [c["doc_id"] for c in chunks]
|
|||||||
assert len(ids) == len(set(ids)), f"duplicate ids: {ids}"
|
assert len(ids) == len(set(ids)), f"duplicate ids: {ids}"
|
||||||
|
|
||||||
faq_ids = sorted(c["doc_id"] for c in chunks if c["type"] == "faq")
|
faq_ids = sorted(c["doc_id"] for c in chunks if c["type"] == "faq")
|
||||||
assert faq_ids == ["faq:0:1", "faq:1:1"], faq_ids
|
assert faq_ids == ["faq:faq:0:1", "faq:faq:1:1"], faq_ids
|
||||||
|
|
||||||
|
# سندهایی که url یکتا دارند (پروژه/مقاله) نباید روی هم بیفتند — url بخشی از doc_id است
|
||||||
|
two_projects = [
|
||||||
|
{"text": "پروژه الف " + "ت" * 60, "type": "project", "title": "الف", "url": "/projects/alpha"},
|
||||||
|
{"text": "پروژه ب " + "ت" * 60, "type": "project", "title": "ب", "url": "/projects/beta"},
|
||||||
|
]
|
||||||
|
pids = sorted(c["doc_id"] for c in chunk_documents(two_projects))
|
||||||
|
assert pids == ["project:projects-alpha:0:1", "project:projects-beta:0:1"], pids
|
||||||
|
|
||||||
|
# تیتر سندهای تکقطعهای باید در متن امبدشده باشد تا جستوجو پیدایش کند
|
||||||
|
faq_chunks = [c for c in chunks if c["type"] == "faq"]
|
||||||
|
assert all(c["text"].startswith(c["title"]) for c in faq_chunks), [c["text"] for c in faq_chunks]
|
||||||
|
|
||||||
art = [c for c in chunks if c["type"] == "article"]
|
art = [c for c in chunks if c["type"] == "article"]
|
||||||
assert len(art) >= 2 and art[1]["doc_id"] == "article:0:2", [x["doc_id"] for x in art]
|
assert len(art) >= 2 and art[1]["doc_id"] == "article:blog-long:0:2", [x["doc_id"] for x in art]
|
||||||
assert "بخش 2 از" in art[1]["text"] and "بخش 1 از" in art[0]["text"]
|
assert "بخش 2 از" in art[1]["text"] and "بخش 1 از" in art[0]["text"]
|
||||||
|
|
||||||
c = chunks[0]
|
c = chunks[0]
|
||||||
|
|||||||
@@ -66,6 +66,22 @@ def ensure_schema() -> None:
|
|||||||
ON chatbot_chunks USING hnsw (embedding vector_cosine_ops)
|
ON chatbot_chunks USING hnsw (embedding vector_cosine_ops)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
# گزارش گفتگوها — حلقه بازخورد برای کشف پرسشهای بیپاسخ
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_logs (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
thread_id TEXT NOT NULL,
|
||||||
|
question TEXT NOT NULL,
|
||||||
|
answer TEXT NOT NULL,
|
||||||
|
n_docs INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_fallback BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
latency_ms INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS chat_logs_ts_idx ON chat_logs (ts DESC)")
|
||||||
|
|
||||||
|
|
||||||
def sync_chunks(chunks: list[dict], force: bool = False) -> dict:
|
def sync_chunks(chunks: list[dict], force: bool = False) -> dict:
|
||||||
@@ -168,6 +184,61 @@ def vector_count() -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def log_chat(thread_id: str, question: str, answer: str, n_docs: int,
|
||||||
|
is_fallback: bool, latency_ms: int) -> None:
|
||||||
|
"""ثبت یک گفتگو در chat_logs — هرگز خطا نمیدهد (لاگ نباید جریان پاسخ را بشکند)."""
|
||||||
|
try:
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=5) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO chat_logs (thread_id, question, answer, n_docs, is_fallback, latency_ms)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(thread_id, question, answer[:4000], n_docs, is_fallback, latency_ms),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("chat_logs insert failed: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def chat_stats(days: int = 7) -> dict:
|
||||||
|
"""آمار تجمعی گفتگوها — بدون متن پرسشها (متنها فقط با توکن در /chat/stats)."""
|
||||||
|
try:
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=5) as conn:
|
||||||
|
total = int(conn.execute("SELECT count(*) FROM chat_logs").fetchone()[0])
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT count(*), count(*) FILTER (WHERE is_fallback)
|
||||||
|
FROM chat_logs WHERE ts > now() - (%s || ' days')::interval
|
||||||
|
""",
|
||||||
|
(str(days),),
|
||||||
|
).fetchone()
|
||||||
|
recent_fallbacks: list[str] = []
|
||||||
|
if row and row[1]:
|
||||||
|
recent_fallbacks = [
|
||||||
|
r[0] for r in conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT question FROM chat_logs
|
||||||
|
WHERE is_fallback AND ts > now() - (%s || ' days')::interval
|
||||||
|
ORDER BY ts DESC LIMIT 20
|
||||||
|
""",
|
||||||
|
(str(days),),
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"total_logged": total,
|
||||||
|
"last_7d": int(row[0]) if row else 0,
|
||||||
|
"fallback_7d": int(row[1]) if row else 0,
|
||||||
|
"recent_fallback_questions": recent_fallbacks,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("chat_stats failed: %s", e)
|
||||||
|
return {"total_logged": 0, "last_7d": 0, "fallback_7d": 0, "recent_fallback_questions": []}
|
||||||
|
|
||||||
|
|
||||||
def dense_search(query: str, top_k: int) -> list[tuple[dict, float]]:
|
def dense_search(query: str, top_k: int) -> list[tuple[dict, float]]:
|
||||||
"""جستوجوی معنایی SQL با فاصله کسینوسی — خروجی: (سند، شباهت).
|
"""جستوجوی معنایی SQL با فاصله کسینوسی — خروجی: (سند، شباهت).
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,9 @@ services:
|
|||||||
- traefik.http.routers.api.entrypoints=websecure
|
- traefik.http.routers.api.entrypoints=websecure
|
||||||
- traefik.http.routers.api.tls.certresolver=le
|
- traefik.http.routers.api.tls.certresolver=le
|
||||||
- traefik.http.routers.api.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}api-flood,sec-headers
|
- traefik.http.routers.api.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}api-flood,sec-headers
|
||||||
- traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`))
|
# سئو: همهٔ صفحات عمومی از بکاند سرو میشوند تا متا در HTML اولیه تزریق شود —
|
||||||
|
# مسیرهای استاتیک دقیقاند (Path) تا assets/ فونتها از Caddy سرو شوند
|
||||||
|
- traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (Path(`/`) || Path(`/about`) || Path(`/services`) || Path(`/investment`) || Path(`/faq`) || Path(`/contact`) || PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`) || PathPrefix(`/llms.txt`))
|
||||||
- traefik.http.routers.seo.entrypoints=websecure
|
- traefik.http.routers.seo.entrypoints=websecure
|
||||||
- traefik.http.routers.seo.tls.certresolver=le
|
- traefik.http.routers.seo.tls.certresolver=le
|
||||||
- traefik.http.routers.seo.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}sec-headers
|
- traefik.http.routers.seo.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}sec-headers
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<!-- پیشبارگذاری فونتهای اصلی — بدون این، swap فونت با تأخیر میآید (LCP) -->
|
||||||
|
<link rel="preload" href="/fonts/pinar/Pinar-Light.woff2" as="font" type="font/woff2" crossorigin />
|
||||||
|
<link rel="preload" href="/fonts/pinar/Pinar-Regular.woff2" as="font" type="font/woff2" crossorigin />
|
||||||
|
<link rel="preload" href="/fonts/pinar/Pinar-Bold.woff2" as="font" type="font/woff2" crossorigin />
|
||||||
<title>گروه ساختمانی آرامش</title>
|
<title>گروه ساختمانی آرامش</title>
|
||||||
<meta name="description" content="گروه ساختمانی آرامش — طراحی، اجرا و مدیریت پروژههای ساختمانی؛ خدمات مهندسی، پیمانکاری و سرمایهگذاری ساختوساز." />
|
<meta name="description" content="گروه ساختمانی آرامش — طراحی، اجرا و مدیریت پروژههای ساختمانی؛ خدمات مهندسی، پیمانکاری و سرمایهگذاری ساختوساز." />
|
||||||
<meta property="og:site_name" content="گروه ساختمانی آرامش" />
|
<meta property="og:site_name" content="گروه ساختمانی آرامش" />
|
||||||
|
|||||||
Generated
+57
@@ -11,6 +11,10 @@
|
|||||||
"@emotion/react": "^11.13.3",
|
"@emotion/react": "^11.13.3",
|
||||||
"@tiptap/extension-image": "^3.31.3",
|
"@tiptap/extension-image": "^3.31.3",
|
||||||
"@tiptap/extension-link": "^3.31.3",
|
"@tiptap/extension-link": "^3.31.3",
|
||||||
|
"@tiptap/extension-table": "^3.31.3",
|
||||||
|
"@tiptap/extension-table-cell": "^3.31.3",
|
||||||
|
"@tiptap/extension-table-header": "^3.31.3",
|
||||||
|
"@tiptap/extension-table-row": "^3.31.3",
|
||||||
"@tiptap/pm": "^3.31.3",
|
"@tiptap/pm": "^3.31.3",
|
||||||
"@tiptap/react": "^3.31.3",
|
"@tiptap/react": "^3.31.3",
|
||||||
"@tiptap/starter-kit": "^3.31.3",
|
"@tiptap/starter-kit": "^3.31.3",
|
||||||
@@ -1866,6 +1870,59 @@
|
|||||||
"@tiptap/core": "3.31.3"
|
"@tiptap/core": "3.31.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/extension-table": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-7cnVPHhdiGGeauYqca6JVyPLTqZbqFEEk9nn2e2E8+fBo6zVtV07AktJBqth/XEzjZxcUmGxjoeuWYAisWjUHg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.31.3",
|
||||||
|
"@tiptap/pm": "3.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-table-cell": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-5nueKR/p/IX6B4etWqHjyRsjNfDf6dJZaRgfw1/1l90CJe2h3tTZk4JbUKWN3Mo4FzZuYKzmU3fxcZ8pSVOeJQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-table": "3.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-table-header": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-sstVtNQiBYX4P16vlhwBYPsthxDnodHWQfGq0EUQM40Z7H796Lgh9t1qYtu8yARQuHKzvINRwJCSy6DZ8YTDtQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-table": "3.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-table-row": {
|
||||||
|
"version": "3.31.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-3.31.3.tgz",
|
||||||
|
"integrity": "sha512-up6tDK+hYVTFDeJ3XqKO0WJqBzyXNfGxMoimo5h83jrq9dcNh0oPCOzglZ7rdo8GH9I3VJ2fNKxxU2Eg2HcMoQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-table": "3.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tiptap/extension-text": {
|
"node_modules/@tiptap/extension-text": {
|
||||||
"version": "3.31.3",
|
"version": "3.31.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.3.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,10 @@
|
|||||||
"@emotion/react": "^11.13.3",
|
"@emotion/react": "^11.13.3",
|
||||||
"@tiptap/extension-image": "^3.31.3",
|
"@tiptap/extension-image": "^3.31.3",
|
||||||
"@tiptap/extension-link": "^3.31.3",
|
"@tiptap/extension-link": "^3.31.3",
|
||||||
|
"@tiptap/extension-table": "^3.31.3",
|
||||||
|
"@tiptap/extension-table-cell": "^3.31.3",
|
||||||
|
"@tiptap/extension-table-header": "^3.31.3",
|
||||||
|
"@tiptap/extension-table-row": "^3.31.3",
|
||||||
"@tiptap/pm": "^3.31.3",
|
"@tiptap/pm": "^3.31.3",
|
||||||
"@tiptap/react": "^3.31.3",
|
"@tiptap/react": "^3.31.3",
|
||||||
"@tiptap/starter-kit": "^3.31.3",
|
"@tiptap/starter-kit": "^3.31.3",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
@@ -5,7 +5,8 @@ import { Preloader } from './components/Preloader';
|
|||||||
import { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
import { Footer } from './components/Footer';
|
import { Footer } from './components/Footer';
|
||||||
import { FloatingContact } from './components/FloatingContact';
|
import { FloatingContact } from './components/FloatingContact';
|
||||||
import { ChatWidget } from './components/ChatWidget';
|
// چتبات موقتاً خاموش است — برای روشنکردن، این ایمپورت و خط رندر پایین را برگردانید
|
||||||
|
// import { ChatWidget } from './components/ChatWidget';
|
||||||
import { Home } from './pages/Home';
|
import { Home } from './pages/Home';
|
||||||
import { About } from './pages/About';
|
import { About } from './pages/About';
|
||||||
import { ServicesPage } from './pages/ServicesPage';
|
import { ServicesPage } from './pages/ServicesPage';
|
||||||
@@ -96,11 +97,14 @@ function AppChrome() {
|
|||||||
const meta = data.seo_meta?.find((s) => s.page_key === key);
|
const meta = data.seo_meta?.find((s) => s.page_key === key);
|
||||||
const brand = data.brand.name;
|
const brand = data.brand.name;
|
||||||
const navLabel = data.nav.find((n) => n.href === `/${key}`)?.label;
|
const navLabel = data.nav.find((n) => n.href === `/${key}`)?.label;
|
||||||
|
// alternate انگلیسی فقط وقتی اعلام میشود که متای انگلیسی واقعاً ثبت شده باشد
|
||||||
|
const hasEn = !!(meta && ((meta.title_en ?? '').trim() || (meta.description_en ?? '').trim()));
|
||||||
applySeo({
|
applySeo({
|
||||||
title: meta?.title || (key === 'home' ? brand : navLabel ? `${navLabel} | ${brand}` : brand),
|
title: meta?.title || (key === 'home' ? brand : navLabel ? `${navLabel} | ${brand}` : brand),
|
||||||
description: meta?.description ?? '',
|
description: meta?.description ?? '',
|
||||||
path: pathname,
|
path: pathname,
|
||||||
lang,
|
lang,
|
||||||
|
hasEn,
|
||||||
});
|
});
|
||||||
}, [pathname, data, isAdminPage, lang]);
|
}, [pathname, data, isAdminPage, lang]);
|
||||||
|
|
||||||
@@ -110,7 +114,8 @@ function AppChrome() {
|
|||||||
<AnimatedRoutes />
|
<AnimatedRoutes />
|
||||||
{!isAdminPage && <Footer />}
|
{!isAdminPage && <Footer />}
|
||||||
{!isAdminPage && <FloatingContact />}
|
{!isAdminPage && <FloatingContact />}
|
||||||
{!isAdminPage && <ChatWidget />}
|
{/* چتبات موقتاً خاموش است — برای روشنکردن، این خط را از حالت کامنت خارج کنید */}
|
||||||
|
{/* {!isAdminPage && <ChatWidget />} */}
|
||||||
{isAuthenticated && !isAdminPage && <AdminBar />}
|
{isAuthenticated && !isAdminPage && <AdminBar />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { CategorySelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
|
|||||||
import type { CategoryOption } from '../components/admin/LuxeSelect'
|
import type { CategoryOption } from '../components/admin/LuxeSelect'
|
||||||
import { ARTICLE_CATEGORIES } from './entities'
|
import { ARTICLE_CATEGORIES } from './entities'
|
||||||
import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants'
|
import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants'
|
||||||
import { formatReadingTime } from '../utils/readingTime'
|
import { formatReadingTime, readingTimeMinutes } from '../utils/readingTime'
|
||||||
import { toArticleBodyDoc } from '../utils/tiptapDoc'
|
import { toArticleBodyDoc } from '../utils/tiptapDoc'
|
||||||
import type { Article, ArticleBodyDoc } from '../data/site'
|
import type { Article, ArticleBodyDoc } from '../data/site'
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ const STATUS_TEXT: Record<string, string> = {
|
|||||||
* مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش).
|
* مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش).
|
||||||
*/
|
*/
|
||||||
export function ArticleWritePage() {
|
export function ArticleWritePage() {
|
||||||
const { isAuthenticated, saveStatus, runSave, editLang, toggleEditLang, role } = useAdmin()
|
const { isAuthenticated, saveStatus, runSave, editLang, toggleEditLang, syncEditLang, role } = useAdmin()
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { data, updateSite } = useSiteData()
|
const { data, updateSite } = useSiteData()
|
||||||
@@ -219,6 +219,23 @@ export function ArticleWritePage() {
|
|||||||
|
|
||||||
const article = existing ?? fetched
|
const article = existing ?? fetched
|
||||||
|
|
||||||
|
// زبان ویرایش این مقاله بهازای خودش بهخاطر سپرده میشود — رفرش تب را عوض نکند
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return
|
||||||
|
const saved = localStorage.getItem(`aramesh_write_lang_${id}`)
|
||||||
|
if (saved === 'en' || saved === 'fa') {
|
||||||
|
syncEditLang(saved)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const art = existing ?? null
|
||||||
|
if (art) {
|
||||||
|
const hasEn = Boolean((art as Draft).title_en?.trim()) || Boolean((art as Draft).body_en as unknown)
|
||||||
|
const hasFa = Boolean((art as Draft).title?.trim())
|
||||||
|
if (hasEn && !hasFa) syncEditLang('en')
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [id, Boolean(article)])
|
||||||
|
|
||||||
// وقتی مقاله موجود شناخته شد، فرم و شناسه ساختهشده یکبار مقداردهی میشود
|
// وقتی مقاله موجود شناخته شد، فرم و شناسه ساختهشده یکبار مقداردهی میشود
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!article || createdRef.current) return
|
if (!article || createdRef.current) return
|
||||||
@@ -327,7 +344,7 @@ export function ArticleWritePage() {
|
|||||||
setDeleting(true)
|
setDeleting(true)
|
||||||
await runSave(async () => {
|
await runSave(async () => {
|
||||||
await adminApi.remove('articles', createdId)
|
await adminApi.remove('articles', createdId)
|
||||||
navigate('/blog')
|
navigate(`${editLang === 'en' ? '/en' : ''}/blog`)
|
||||||
})
|
})
|
||||||
setDeleting(false)
|
setDeleting(false)
|
||||||
}
|
}
|
||||||
@@ -338,9 +355,21 @@ export function ArticleWritePage() {
|
|||||||
queueSave({ [name]: e.target.value }),
|
queueSave({ [name]: e.target.value }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const metaLength = (draft.meta_description ?? '').trim().length
|
|
||||||
// در زبان انگلیسی، فیلدهای متنی به ستونهای _en میروند؛ اسلاگ/تصویر/وضعیت مشترکاند
|
// در زبان انگلیسی، فیلدهای متنی به ستونهای _en میروند؛ اسلاگ/تصویر/وضعیت مشترکاند
|
||||||
const suf = <N extends string>(name: N) => `${name}${editLang === 'en' ? '_en' : ''}` as keyof Draft
|
const suf = <N extends string>(name: N) => `${name}${editLang === 'en' ? '_en' : ''}` as keyof Draft
|
||||||
|
// مقدار فعال فیلد دوزبانه — برای value ورودیها و چکلیست سئو
|
||||||
|
// جهت ویرایشگر محتوایی با زبان ویرایش همگام میشود — صفحهٔ انگلیسی LTR بماند
|
||||||
|
useEffect(() => {
|
||||||
|
const el = document.querySelector('.article-body[data-wysiwyg="true"]')
|
||||||
|
if (el) {
|
||||||
|
el.setAttribute('dir', editLang === 'en' ? 'ltr' : 'rtl')
|
||||||
|
el.setAttribute('lang', editLang)
|
||||||
|
}
|
||||||
|
}, [editLang, createdId])
|
||||||
|
|
||||||
|
const activeVal = (name: 'title' | 'slug' | 'author' | 'seo_keyphrase' | 'meta_description' | 'excerpt'): string =>
|
||||||
|
String((editLang === 'en' ? draft[`${name}_en` as keyof Draft] : draft[name]) ?? '')
|
||||||
|
const metaLength = activeVal('meta_description').trim().length
|
||||||
|
|
||||||
// متن جایگزین تصویر شاخص — با watch('title') بهصورت زنده ساخته میشود
|
// متن جایگزین تصویر شاخص — با watch('title') بهصورت زنده ساخته میشود
|
||||||
const imageAltFallback = [watchedTitle.trim(), FEATURED_IMAGE_ALT_SUFFIX]
|
const imageAltFallback = [watchedTitle.trim(), FEATURED_IMAGE_ALT_SUFFIX]
|
||||||
@@ -348,12 +377,12 @@ export function ArticleWritePage() {
|
|||||||
.join(' - ')
|
.join(' - ')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div dir="rtl" className="min-h-screen bg-ink font-pinar text-ivory">
|
<div dir={editLang === 'en' ? 'ltr' : 'rtl'} className="min-h-screen bg-ink font-pinar text-ivory">
|
||||||
{/* نوار بالا */}
|
{/* نوار بالا */}
|
||||||
<header className="sticky top-0 z-40 border-b border-ivory/10 bg-ink/95 backdrop-blur-md">
|
<header className="sticky top-0 z-40 border-b border-ivory/10 bg-ink/95 backdrop-blur-md">
|
||||||
<div className="mx-auto flex max-w-4xl items-center justify-between gap-4 px-6 py-3">
|
<div className="mx-auto flex max-w-4xl items-center justify-between gap-4 px-6 py-3">
|
||||||
<Link
|
<Link
|
||||||
to="/blog"
|
to={`${editLang === 'en' ? '/en' : ''}/blog`}
|
||||||
data-admin-link
|
data-admin-link
|
||||||
className="flex items-center gap-2 text-sm font-light text-ivory/70 transition-colors hover:text-copper"
|
className="flex items-center gap-2 text-sm font-light text-ivory/70 transition-colors hover:text-copper"
|
||||||
>
|
>
|
||||||
@@ -364,7 +393,11 @@ export function ArticleWritePage() {
|
|||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleEditLang}
|
onClick={() => {
|
||||||
|
const next = editLang === 'en' ? 'fa' : 'en'
|
||||||
|
toggleEditLang()
|
||||||
|
if (createdId) localStorage.setItem(`aramesh_write_lang_${createdId}`, next)
|
||||||
|
}}
|
||||||
title="تعیین میکند متن مقاله روی کدام زبان ذخیره شود"
|
title="تعیین میکند متن مقاله روی کدام زبان ذخیره شود"
|
||||||
className={`px-4 py-2 text-xs font-bold transition-colors duration-200 ${
|
className={`px-4 py-2 text-xs font-bold transition-colors duration-200 ${
|
||||||
editLang === 'en'
|
editLang === 'en'
|
||||||
@@ -410,7 +443,7 @@ export function ArticleWritePage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<Link
|
<Link
|
||||||
to={`${editLang === 'en' ? '/en' : ''}/blog/${draft.slug}`}
|
to={`${editLang === 'en' ? '/en' : ''}/blog/${editLang === 'en' ? draft.slug_en || draft.slug : draft.slug}`}
|
||||||
data-admin-link
|
data-admin-link
|
||||||
className="flex items-center gap-1.5 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/70 transition-colors hover:border-copper hover:text-copper"
|
className="flex items-center gap-1.5 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/70 transition-colors hover:border-copper hover:text-copper"
|
||||||
>
|
>
|
||||||
@@ -451,7 +484,7 @@ export function ArticleWritePage() {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
{...field}
|
value={editLang === 'en' ? String(draft.title_en ?? '') : field.value}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
field.onChange(event.target.value)
|
field.onChange(event.target.value)
|
||||||
queueSave({ [suf('title')]: event.target.value })
|
queueSave({ [suf('title')]: event.target.value })
|
||||||
@@ -493,14 +526,16 @@ export function ArticleWritePage() {
|
|||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<ArticleEditor
|
<ArticleEditor
|
||||||
contentKey={loadedContentKey}
|
contentKey={loadedContentKey}
|
||||||
|
contentLang={editLang}
|
||||||
doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>}
|
doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>}
|
||||||
onChange={(json) => {
|
onChange={(json) => {
|
||||||
// زمان مطالعه بهصورت پویا از متن سند محاسبه میشود — به زبان جاری
|
// زمان مطالعه بهصورت پویا از متن سند محاسبه میشود — به زبان جاری
|
||||||
const minutes = formatReadingTime(json)
|
const minutes = readingTimeMinutes(json)
|
||||||
if (editLang === 'en') {
|
if (editLang === 'en') {
|
||||||
queueSave({ [suf('body')]: json, [suf('read')]: `${minutes} min read` })
|
queueSave({ [suf('body')]: json, [suf('read')]: `${minutes} min read` })
|
||||||
} else {
|
} else {
|
||||||
queueSave({ body: json, read: `${minutes} دقیقه` })
|
// formatReadingTime خودش برچسب «مطالعه» را دارد — پسوند اضافه ممنوع
|
||||||
|
queueSave({ body: json, read: formatReadingTime(json) })
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -524,8 +559,8 @@ export function ArticleWritePage() {
|
|||||||
<textarea
|
<textarea
|
||||||
id="meta-description"
|
id="meta-description"
|
||||||
rows={3}
|
rows={3}
|
||||||
value={draft.meta_description ?? ''}
|
value={activeVal('meta_description')}
|
||||||
onChange={(e) => queueSave({ meta_description: e.target.value })}
|
onChange={(e) => queueSave({ [suf('meta_description')]: e.target.value })}
|
||||||
placeholder="توضیح جذابی برای گوگل بنویسید که شامل کلمه کلیدی باشد…"
|
placeholder="توضیح جذابی برای گوگل بنویسید که شامل کلمه کلیدی باشد…"
|
||||||
className="mt-3 w-full resize-y border border-ivory/15 bg-ink px-4 py-3 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
|
className="mt-3 w-full resize-y border border-ivory/15 bg-ink px-4 py-3 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
|
||||||
/>
|
/>
|
||||||
@@ -548,11 +583,12 @@ export function ArticleWritePage() {
|
|||||||
{/* چکیده و متادیتا */}
|
{/* چکیده و متادیتا */}
|
||||||
<SeoChecklist
|
<SeoChecklist
|
||||||
input={{
|
input={{
|
||||||
title: draft.title,
|
title: activeVal('title'),
|
||||||
slug: draft.slug,
|
slug: activeVal('slug'),
|
||||||
excerpt: draft.meta_description || draft.excerpt,
|
excerpt: activeVal('meta_description') || activeVal('excerpt'),
|
||||||
body: draft.body,
|
body: (editLang === 'en' ? draft.body_en : draft.body) ?? draft.body,
|
||||||
keyphrase: draft.seo_keyphrase,
|
keyphrase: activeVal('seo_keyphrase'),
|
||||||
|
author: draft.author,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -564,8 +600,8 @@ export function ArticleWritePage() {
|
|||||||
<input
|
<input
|
||||||
id="seo-keyphrase"
|
id="seo-keyphrase"
|
||||||
type="text"
|
type="text"
|
||||||
value={draft.seo_keyphrase}
|
value={activeVal('seo_keyphrase')}
|
||||||
onChange={(e) => queueSave({ seo_keyphrase: e.target.value })}
|
onChange={(e) => queueSave({ [suf('seo_keyphrase')]: e.target.value })}
|
||||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -585,7 +621,7 @@ export function ArticleWritePage() {
|
|||||||
<input
|
<input
|
||||||
id="article-author"
|
id="article-author"
|
||||||
type="text"
|
type="text"
|
||||||
{...textField('author')}
|
{...textField(suf('author'))}
|
||||||
placeholder="نام و نام خانوادگی نویسنده"
|
placeholder="نام و نام خانوادگی نویسنده"
|
||||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none"
|
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none"
|
||||||
/>
|
/>
|
||||||
@@ -594,31 +630,42 @@ export function ArticleWritePage() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-light text-ivory/50">دستهبندی مقاله (Category)</label>
|
<label className="text-xs font-light text-ivory/50">
|
||||||
|
دستهبندی مقاله (Category){editLang === 'en' ? ' — نسخه انگلیسی' : ''}
|
||||||
|
</label>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
{/* تکانتخابی: ورودی/خروجی Controller یک {value,label} یا null است */}
|
{editLang === 'en' ? (
|
||||||
<Controller
|
<input
|
||||||
name="category"
|
type="text"
|
||||||
control={control}
|
{...textField('category_en')}
|
||||||
render={({ field }) => (
|
placeholder={draft.category ? `English category (فارسی: ${draft.category})…` : 'English category…'}
|
||||||
<CategorySelect
|
className="w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||||
value={field.value}
|
/>
|
||||||
options={ARTICLE_CATEGORIES}
|
) : (
|
||||||
onChange={(option) => {
|
/* تکانتخابی: ورودی/خروجی Controller یک {value,label} یا null است */
|
||||||
field.onChange(option)
|
<Controller
|
||||||
queueSave({ category: categoryValue(option) })
|
name="category"
|
||||||
}}
|
control={control}
|
||||||
/>
|
render={({ field }) => (
|
||||||
)}
|
<CategorySelect
|
||||||
/>
|
value={field.value}
|
||||||
|
options={ARTICLE_CATEGORIES}
|
||||||
|
onChange={(option) => {
|
||||||
|
field.onChange(option)
|
||||||
|
queueSave({ category: categoryValue(option) })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-light text-ivory/50">هشتگها / برچسبها</label>
|
<label className="text-xs font-light text-ivory/50">هشتگها / برچسبها</label>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<LuxeTagsSelect
|
<LuxeTagsSelect
|
||||||
value={normalizeTags(draft.tags)}
|
value={normalizeTags(editLang === 'en' ? draft.tags_en : draft.tags)}
|
||||||
onChange={(tags) => queueSave({ tags })}
|
onChange={(tags) => queueSave({ [suf('tags')]: tags })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -653,7 +700,7 @@ export function ArticleWritePage() {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
{...textField('slug')}
|
{...textField(suf('slug'))}
|
||||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,6 +56,13 @@ const EMPTY: ProjectDraft = {
|
|||||||
image: '', gallery: [],
|
image: '', gallery: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** body ممکن است از ردیفهای قدیمی/ناقص غیرآرایه باشد (مثلاً {} یا رشته) — همیشه آرایهٔ رشته */
|
||||||
|
function asLines(value: unknown): string[] {
|
||||||
|
if (Array.isArray(value)) return value.map((line) => String(line))
|
||||||
|
if (typeof value === 'string') return value.split('\n')
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
const INPUT =
|
const INPUT =
|
||||||
'w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
|
'w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
|
||||||
|
|
||||||
@@ -313,11 +320,11 @@ export function ProjectWritePage() {
|
|||||||
<Pair
|
<Pair
|
||||||
label="متن پروژه — هر پاراگراف یک خط"
|
label="متن پروژه — هر پاراگراف یک خط"
|
||||||
fa={{
|
fa={{
|
||||||
value: draft.body.join('\n'),
|
value: asLines(draft.body).join('\n'),
|
||||||
onChange: (v) => set('body', v.split('\n')),
|
onChange: (v) => set('body', v.split('\n')),
|
||||||
}}
|
}}
|
||||||
en={{
|
en={{
|
||||||
value: draft.body_en.join('\n'),
|
value: asLines(draft.body_en).join('\n'),
|
||||||
onChange: (v) => set('body_en', v.split('\n')),
|
onChange: (v) => set('body_en', v.split('\n')),
|
||||||
}}
|
}}
|
||||||
textarea
|
textarea
|
||||||
@@ -335,7 +342,7 @@ export function ProjectWritePage() {
|
|||||||
<div className="border-t border-ivory/10 pt-8">
|
<div className="border-t border-ivory/10 pt-8">
|
||||||
<GalleryField
|
<GalleryField
|
||||||
label="گالری تصاویر پروژه — فقط آپلود به MinIO"
|
label="گالری تصاویر پروژه — فقط آپلود به MinIO"
|
||||||
value={draft.gallery}
|
value={asLines(draft.gallery)}
|
||||||
onSave={persistGallery}
|
onSave={persistGallery}
|
||||||
namePrefix={draft.slug || 'project'}
|
namePrefix={draft.slug || 'project'}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
|||||||
F('title', 'عنوان'),
|
F('title', 'عنوان'),
|
||||||
F('body', 'متن مقاله', 'wysiwyg'),
|
F('body', 'متن مقاله', 'wysiwyg'),
|
||||||
F('excerpt', 'چکیده (روی کارت مقاله)', 'textarea'),
|
F('excerpt', 'چکیده (روی کارت مقاله)', 'textarea'),
|
||||||
F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده میشود', true),
|
F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده میشود'),
|
||||||
F('image', 'تصویر شاخص', 'image', undefined, { altFallbackSuffix: FEATURED_IMAGE_ALT_SUFFIX, shared: true }),
|
F('image', 'تصویر شاخص', 'image', undefined, { altFallbackSuffix: FEATURED_IMAGE_ALT_SUFFIX, shared: true }),
|
||||||
F('category', 'دستهبندی (Category)', 'select', 'میتوانید دسته جدید هم بسازید', { options: ARTICLE_CATEGORIES, creatable: true, shared: true }),
|
F('category', 'دستهبندی (Category)', 'select', 'میتوانید دسته جدید هم بسازید', { options: ARTICLE_CATEGORIES, creatable: true, shared: true }),
|
||||||
F('tags', 'هشتگها / برچسبها', 'list', 'با Enter هر هشتگ جدید را اضافه کنید', { ui: 'tags', shared: true }),
|
F('tags', 'هشتگها / برچسبها', 'list', 'با Enter هر هشتگ جدید را اضافه کنید', { ui: 'tags', shared: true }),
|
||||||
@@ -104,6 +104,7 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
|||||||
image_title: '',
|
image_title: '',
|
||||||
author: '',
|
author: '',
|
||||||
meta_description: '',
|
meta_description: '',
|
||||||
|
meta_description_en: '',
|
||||||
read: '۵ دقیقه',
|
read: '۵ دقیقه',
|
||||||
excerpt: '',
|
excerpt: '',
|
||||||
seo_keyphrase: '',
|
seo_keyphrase: '',
|
||||||
@@ -210,7 +211,7 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
|||||||
brand: {
|
brand: {
|
||||||
resource: 'brand', dataKey: 'brand', title: 'برند و تماس', kind: 'singleton',
|
resource: 'brand', dataKey: 'brand', title: 'برند و تماس', kind: 'singleton',
|
||||||
titleKey: 'name',
|
titleKey: 'name',
|
||||||
fields: [F('name', 'نام برند'), F('logo', 'لوگو', 'image', undefined, true), F('hero', 'تصویر هیرو', 'image', undefined, true), F('contact_bg', 'پسزمینه بخش تماس', 'image', undefined, true), F('phone', 'تلفن نمایشی', 'text', undefined, true), F('phone_href', 'لینک تلفن', 'text', undefined, true), F('email', 'ایمیل', 'text', undefined, true), F('address', 'نشانی', 'textarea')],
|
fields: [F('name', 'نام برند'), F('logo', 'لوگو', 'image'), F('hero', 'تصویر هیرو', 'image', undefined, true), F('contact_bg', 'پسزمینه بخش تماس', 'image', undefined, true), F('phone', 'تلفن نمایشی', 'text', undefined, true), F('phone_href', 'لینک تلفن', 'text', undefined, true), F('email', 'ایمیل', 'text', undefined, true), F('address', 'نشانی', 'textarea')],
|
||||||
defaults: {},
|
defaults: {},
|
||||||
},
|
},
|
||||||
hero: {
|
hero: {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type BrandInfo = {
|
|||||||
name: string
|
name: string
|
||||||
name_en?: string
|
name_en?: string
|
||||||
logo: string
|
logo: string
|
||||||
|
logo_en?: string
|
||||||
hero: string
|
hero: string
|
||||||
contact_bg: string
|
contact_bg: string
|
||||||
phone: string
|
phone: string
|
||||||
|
|||||||
@@ -1,53 +1,54 @@
|
|||||||
import React from 'react'
|
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
|
import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
|
||||||
import type { Article } from '../data/site'
|
import type { Article } from '../data/site'
|
||||||
import { formatJalali } from '../utils/date'
|
import { formatJalali, formatEnDate } from '../utils/date'
|
||||||
import { useLang } from '../i18n/lang'
|
import { useLang } from '../i18n/lang'
|
||||||
import { toPersianDigits } from '../utils/digits'
|
import { toPersianDigits } from '../utils/digits'
|
||||||
|
|
||||||
export function ArticleCard({ article }: { article: Article }) {
|
export function ArticleCard({ article }: { article: Article }) {
|
||||||
const { t, href } = useLang()
|
const { t, href, lang } = useLang()
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={href(`/blog/${article.slug}`)}
|
to={href(`/blog/${article.slug}`)}
|
||||||
className="group flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-char/80 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.18)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
className="group flex h-full flex-col overflow-hidden rounded-xl md:rounded-2xl border border-white/10 bg-char/80 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.18)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||||
aria-label={`${t('readArticlePrefix')} ${article.title}`}
|
aria-label={`${t('readArticlePrefix')} ${article.title}`}
|
||||||
>
|
>
|
||||||
<div className="relative overflow-hidden">
|
<div className="relative overflow-hidden aspect-[16/10] w-full">
|
||||||
<img
|
<img
|
||||||
src={article.image}
|
src={article.image}
|
||||||
alt={`${t('articleImagePrefix')} ${article.title}`}
|
alt={`${t('articleImagePrefix')} ${article.title}`}
|
||||||
className="h-52 w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
|
className="h-52 md:h-full w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
{/* برچسب دستهبندی کپسولی شیشهای روی تصویر */}
|
{/* برچسب دستهبندی کپسولی شیشهای روی تصویر */}
|
||||||
<div className="absolute top-4 ltr:left-4 rtl:right-4 rounded-full border border-white/20 bg-ink/75 px-3.5 py-1 text-xs font-light text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)]">
|
<div className="absolute top-3 ltr:left-3 rtl:right-3 md:top-4 md:ltr:left-4 md:rtl:right-4 rounded-full border border-white/20 bg-ink/75 px-2.5 py-0.5 md:px-3.5 md:py-1 text-[11px] md:text-xs font-light text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)]">
|
||||||
{article.category}
|
{article.category}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col p-8">
|
<div className="flex flex-1 flex-col p-3.5 md:p-8">
|
||||||
<h3 className="mb-4 md:mb-6 text-xl font-bold leading-relaxed text-ivory transition-colors duration-300 group-hover:text-copper">
|
<h3 className="mb-1.5 md:mb-6 text-xs md:text-xl font-bold leading-snug md:leading-relaxed text-ivory line-clamp-2 transition-colors duration-300 group-hover:text-copper">
|
||||||
{article.title}
|
{article.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<p className="mb-6 md:mb-10 text-[15px] font-light leading-loose text-ivory/60 line-clamp-3">
|
<p className="mb-2.5 md:mb-10 text-[11px] md:text-[15px] font-light leading-relaxed md:leading-loose text-ivory/60 line-clamp-2 md:line-clamp-3">
|
||||||
{article.excerpt}
|
{article.excerpt}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-6 md:pt-8 text-xs font-light text-ivory/50">
|
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-2.5 md:pt-8 text-[11px] md:text-xs font-light text-ivory/50">
|
||||||
<span className="flex items-center gap-4">
|
<span className="flex items-center gap-2.5 md:gap-4">
|
||||||
<span>{formatJalali(article.date)}</span>
|
<span>{lang === 'en' ? formatEnDate(article.date) : formatJalali(article.date)}</span>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-1.5 md:gap-2">
|
||||||
<ClockIcon className="h-3.5 w-3.5 text-copper/80" />
|
<ClockIcon className="h-3 w-3 md:h-3.5 md:w-3.5 text-copper/80" />
|
||||||
{toPersianDigits(article.read)}
|
{lang === 'en' ? article.read : toPersianDigits(article.read)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* دکمه کپسولی ادامه مطلب با افکت شیشهای */}
|
{/* دکمه کپسولی ادامه مطلب با افکت شیشهای */}
|
||||||
<span className="inline-flex items-center gap-2 rounded-full border border-copper/40 bg-copper/5 px-3.5 py-1.5 text-xs font-medium text-copper backdrop-blur-md shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_15px_rgba(197,143,92,0.5)] rtl:hover:[&>svg]:-translate-x-1 ltr:hover:[&>svg]:translate-x-1">
|
<span className="inline-flex items-center gap-1.5 md:gap-2 rounded-full border border-copper/40 bg-copper/5 px-2.5 py-1 md:px-3.5 md:py-1.5 text-[11px] md:text-xs font-medium text-copper backdrop-blur-md shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_15px_rgba(197,143,92,0.5)] rtl:group-hover:[&>svg]:-translate-x-1 ltr:group-hover:[&>svg]:translate-x-1">
|
||||||
{t('readMore')}
|
{t('readMore')}
|
||||||
<ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100 transition-transform duration-300" />
|
<ArrowLeftIcon className="h-3 w-3 md:h-3.5 md:w-3.5 ltr:-scale-x-100 transition-transform duration-300" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { CalendarIcon, ClockIcon } from 'lucide-react'
|
import { CalendarIcon, ClockIcon } from 'lucide-react'
|
||||||
import type { Article } from '../data/site'
|
import type { Article } from '../data/site'
|
||||||
import { JALALI_TIME_FORMATTER, formatJalali } from '../utils/date'
|
import { JALALI_TIME_FORMATTER, formatJalali, formatEnDate } from '../utils/date'
|
||||||
import { toPersianDigits } from '../utils/digits'
|
import { toPersianDigits } from '../utils/digits'
|
||||||
|
import { useLang } from '../i18n/lang'
|
||||||
|
|
||||||
type ArticleMetaProps = {
|
type ArticleMetaProps = {
|
||||||
article: Article
|
article: Article
|
||||||
@@ -13,24 +14,23 @@ type ArticleMetaProps = {
|
|||||||
* آواتار حرف اول نویسنده، نام نویسنده، تاریخ جلالی و زمان مطالعه.
|
* آواتار حرف اول نویسنده، نام نویسنده، تاریخ جلالی و زمان مطالعه.
|
||||||
*/
|
*/
|
||||||
export function ArticleMeta({ article, className = '' }: ArticleMetaProps) {
|
export function ArticleMeta({ article, className = '' }: ArticleMetaProps) {
|
||||||
|
const { t, lang } = useLang()
|
||||||
const rawDate = (article.date ?? '').trim()
|
const rawDate = (article.date ?? '').trim()
|
||||||
const author = (article.author ?? '').trim() || 'گروه ساختمانی آرامش'
|
const author = (article.author ?? '').trim() || t('brandFallback')
|
||||||
const initial = author.charAt(0)
|
|
||||||
|
|
||||||
// تاریخ ذخیرهشده (میلادی) به شمسی تبدیل میشود؛ در نبود تاریخ، امروز با ساعت نمایش داده میشود
|
// فارسی: تاریخ شمسی با ارقام فارسی؛ انگلیسی: تاریخ میلادی لاتین
|
||||||
|
const enDateLabel = formatEnDate(rawDate)
|
||||||
|
|| new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date())
|
||||||
const todayLabel = `${formatJalali(new Date())} — ساعت ${JALALI_TIME_FORMATTER.format(new Date())}`
|
const todayLabel = `${formatJalali(new Date())} — ساعت ${JALALI_TIME_FORMATTER.format(new Date())}`
|
||||||
const dateLabel = formatJalali(rawDate, todayLabel)
|
const dateLabel = lang === 'en' ? enDateLabel : formatJalali(rawDate, todayLabel)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-wrap items-center gap-x-6 gap-y-4 ${className}`}>
|
<div className={`flex flex-wrap items-center gap-x-6 gap-y-4 ${className}`}>
|
||||||
{/* آواتار و نام نویسنده */}
|
{/* نام نویسنده — بدون دایرهٔ حرف اول */}
|
||||||
<span className="flex items-center gap-3">
|
<span className="flex items-center gap-3">
|
||||||
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full border border-copper/40 bg-copper/15 text-lg font-bold text-copper shadow-[0_0_20px_rgba(213,135,76,0.25),inset_0_1px_1px_rgba(255,255,255,0.2)]">
|
|
||||||
{initial}
|
|
||||||
</span>
|
|
||||||
<span className="flex flex-col">
|
<span className="flex flex-col">
|
||||||
<span className="text-sm font-bold text-ivory">{author}</span>
|
<span className="text-sm font-bold text-ivory">{author}</span>
|
||||||
<span className="text-[11px] font-light text-ivory/45">نویسنده</span>
|
<span className="text-[11px] font-light text-ivory/45">{t('author')}</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ export function ArticleMeta({ article, className = '' }: ArticleMetaProps) {
|
|||||||
{article.read && (
|
{article.read && (
|
||||||
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
|
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
|
||||||
<ClockIcon className="h-3.5 w-3.5 text-copper" aria-hidden="true" />
|
<ClockIcon className="h-3.5 w-3.5 text-copper" aria-hidden="true" />
|
||||||
{toPersianDigits(article.read)}
|
{lang === 'fa' ? toPersianDigits(article.read) : article.read}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ type SseFrame = {
|
|||||||
content?: string;
|
content?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
done?: boolean;
|
done?: boolean;
|
||||||
|
/** true یعنی دستیار چیزی پیدا نکرده و پیام جایگزین داده */
|
||||||
|
fallback?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** شناسه گفتگو برای هر نشست — در sessionStorage میماند تا رفرش صفحه تاریخچه را نگه دارد */
|
/** شناسه گفتگو برای هر نشست — در sessionStorage میماند تا رفرش صفحه تاریخچه را نگه دارد */
|
||||||
@@ -78,7 +80,9 @@ export function ChatWidget() {
|
|||||||
const sendMessage = async (text: string) => {
|
const sendMessage = async (text: string) => {
|
||||||
const question = text.trim();
|
const question = text.trim();
|
||||||
if (!question || isLoading) return;
|
if (!question || isLoading) return;
|
||||||
track('chat-question')
|
// نتیجهٔ هر پرسش برای تحلیل گزارش میشود — پرسشهای بیپاسخ = شکاف دانش
|
||||||
|
let hadError = false;
|
||||||
|
let wasFallback = false;
|
||||||
setInput('');
|
setInput('');
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
@@ -111,6 +115,7 @@ export function ChatWidget() {
|
|||||||
let accumulated = '';
|
let accumulated = '';
|
||||||
await readSse(response, (frame) => {
|
await readSse(response, (frame) => {
|
||||||
if (frame.error) {
|
if (frame.error) {
|
||||||
|
hadError = true;
|
||||||
patchAssistant({ content: frame.error, streaming: false, error: true });
|
patchAssistant({ content: frame.error, streaming: false, error: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -118,16 +123,21 @@ export function ChatWidget() {
|
|||||||
accumulated += frame.content;
|
accumulated += frame.content;
|
||||||
patchAssistant({ content: accumulated });
|
patchAssistant({ content: accumulated });
|
||||||
}
|
}
|
||||||
if (frame.done) patchAssistant({ content: accumulated || 'پاسخی دریافت نشد.', streaming: false });
|
if (frame.done) {
|
||||||
|
wasFallback = frame.fallback === true;
|
||||||
|
patchAssistant({ content: accumulated || 'پاسخی دریافت نشد.', streaming: false });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
patchAssistant({ streaming: false });
|
patchAssistant({ streaming: false });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
hadError = true;
|
||||||
patchAssistant({
|
patchAssistant({
|
||||||
content: 'سرویس گفتگو در دسترس نیست؛ لطفاً کمی بعد دوباره تلاش کنید.',
|
content: 'سرویس گفتگو در دسترس نیست؛ لطفاً کمی بعد دوباره تلاش کنید.',
|
||||||
streaming: false,
|
streaming: false,
|
||||||
error: true,
|
error: true,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
track('chat-question', { fallback: wasFallback, error: hadError });
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ import { InlineEdit } from './admin/InlineEdit'
|
|||||||
import { SingletonPill } from '../admin/Editable'
|
import { SingletonPill } from '../admin/Editable'
|
||||||
import { API_URL } from '../config'
|
import { API_URL } from '../config'
|
||||||
|
|
||||||
export function ContactSection() {
|
export function ContactSection({ bare = false }: { bare?: boolean } = {}) {
|
||||||
const { brand, contact_section: content } = useSite()
|
const { brand, contact_section: content } = useSite()
|
||||||
const { t } = useLang()
|
const { t } = useLang()
|
||||||
const save = useFieldSaver()
|
const save = useFieldSaver()
|
||||||
const [form, setForm] = useState({ name: '', phone: '', note: '' })
|
const [form, setForm] = useState({ name: '', phone: '', note: '', website: '' })
|
||||||
const [sent, setSent] = useState(false)
|
const [sent, setSent] = useState(false)
|
||||||
const [sending, setSending] = useState(false)
|
const [sending, setSending] = useState(false)
|
||||||
|
|
||||||
@@ -98,18 +98,134 @@ export function ContactSection() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cleanOptionalLabel = `(${content.note_optional_label?.replace(/^\(+|\)+$/g, '') || 'اختیاری'})`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="contact" className="relative overflow-hidden py-24 lg:py-32" aria-labelledby="contact-title">
|
<section id="contact" className="relative overflow-hidden py-24 lg:py-32" aria-labelledby="contact-title">
|
||||||
<img
|
{!bare && (
|
||||||
src={brand.contact_bg}
|
<>
|
||||||
alt=""
|
<img
|
||||||
aria-hidden="true"
|
src={brand.contact_bg}
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
alt=""
|
||||||
/>
|
aria-hidden="true"
|
||||||
<div className="absolute inset-0 bg-ink/75 backdrop-blur-[2px]" />
|
className="absolute inset-0 h-full w-full object-cover" loading="lazy" decoding="async"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-ink/75 backdrop-blur-[2px]" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="relative mx-auto max-w-site px-6 lg:px-10">
|
<div className="relative mx-auto max-w-site px-4 md:px-6 lg:px-10">
|
||||||
<div className="mx-auto grid max-w-6xl items-stretch gap-8 lg:grid-cols-12">
|
|
||||||
|
{/* Mobile View: High-Conversion Streamlined Layout */}
|
||||||
|
<div className="block md:hidden px-1 py-1">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-4 text-center">
|
||||||
|
<p className="text-xs font-light tracking-wide text-copper">
|
||||||
|
<InlineEdit value={content.label} onSave={(v) => saveContent('label', v)} />
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-1.5 text-xl font-bold text-ivory">
|
||||||
|
<InlineEdit value={content.title} onSave={(v) => saveContent('title', v)} />
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1.5 text-xs font-light leading-relaxed text-ivory/70 max-w-sm mx-auto">
|
||||||
|
<InlineEdit multiline value={content.lead} onSave={(v) => saveContent('lead', v)} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* High-Conversion Form Box */}
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-char/60 p-4 backdrop-blur-xl shadow-lg space-y-3">
|
||||||
|
{sent ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full border border-copper/40 bg-copper/15 text-copper">
|
||||||
|
<CheckCircle2Icon className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-4 text-base font-bold text-ivory">
|
||||||
|
{content.success_title}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-xs font-light leading-relaxed text-ivory/70">
|
||||||
|
{content.success_text}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSent(false)
|
||||||
|
setForm({ name: '', phone: '', note: '', website: '' })
|
||||||
|
}}
|
||||||
|
className="mt-4 rounded-xl bg-copper px-6 py-2.5 text-xs font-bold text-char transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
{content.success_button_label}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={submit} noValidate className="space-y-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="website_mobile"
|
||||||
|
value={form.website}
|
||||||
|
onChange={(e) => setForm({ ...form, website: e.target.value })}
|
||||||
|
tabIndex={-1}
|
||||||
|
autoComplete="off"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute -left-[9999px] h-0 w-0 opacity-0"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="contact-name-mobile" className="text-[11px] font-semibold text-ivory/80 mb-1 block">
|
||||||
|
{content.name_label}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="contact-name-mobile"
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
placeholder={content.name_placeholder}
|
||||||
|
className="w-full py-2.5 px-3 text-xs rounded-xl border border-white/10 bg-ink/60 text-ivory placeholder:text-ivory/35 backdrop-blur-md focus:border-copper/70 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="contact-phone-mobile" className="text-[11px] font-semibold text-ivory/80 mb-1 block">
|
||||||
|
{content.phone_field_label} <span className="text-copper">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="contact-phone-mobile"
|
||||||
|
type="tel"
|
||||||
|
dir="ltr"
|
||||||
|
required
|
||||||
|
value={form.phone}
|
||||||
|
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||||
|
placeholder={content.phone_placeholder}
|
||||||
|
className="w-full py-2.5 px-3 text-xs rounded-xl border border-white/10 bg-ink/60 text-ivory placeholder:text-ivory/35 backdrop-blur-md focus:border-copper/70 focus:outline-none text-left font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="contact-note-mobile" className="text-[11px] font-semibold text-ivory/80 mb-1 block">
|
||||||
|
{content.note_label} <span className="font-light text-ivory/50">{cleanOptionalLabel}</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="contact-note-mobile"
|
||||||
|
rows={2}
|
||||||
|
value={form.note}
|
||||||
|
onChange={(e) => setForm({ ...form, note: e.target.value })}
|
||||||
|
placeholder={content.note_placeholder}
|
||||||
|
className="w-full py-2.5 px-3 text-xs rounded-xl border border-white/10 bg-ink/60 text-ivory placeholder:text-ivory/35 backdrop-blur-md focus:border-copper/70 focus:outline-none resize-none leading-relaxed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={sending}
|
||||||
|
className="w-full py-3 rounded-xl bg-copper text-char font-bold text-xs shadow-md hover:brightness-110 active:scale-95 transition-all flex items-center justify-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
{sending ? content.sending_label : content.submit_label}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop View */}
|
||||||
|
<div className="hidden md:grid mx-auto max-w-6xl items-stretch gap-8 lg:grid-cols-12">
|
||||||
|
|
||||||
{/* باکس اطلاعات تماس */}
|
{/* باکس اطلاعات تماس */}
|
||||||
<div className="rounded-3xl border border-white/10 bg-char/60 p-8 backdrop-blur-2xl shadow-[0_20px_50px_rgba(0,0,0,0.4),inset_0_1px_1px_rgba(255,255,255,0.15)] md:p-10 lg:col-span-5">
|
<div className="rounded-3xl border border-white/10 bg-char/60 p-8 backdrop-blur-2xl shadow-[0_20px_50px_rgba(0,0,0,0.4),inset_0_1px_1px_rgba(255,255,255,0.15)] md:p-10 lg:col-span-5">
|
||||||
@@ -200,7 +316,7 @@ export function ContactSection() {
|
|||||||
className="mt-8 font-light"
|
className="mt-8 font-light"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSent(false)
|
setSent(false)
|
||||||
setForm({ name: '', phone: '', note: '' })
|
setForm({ name: '', phone: '', note: '', website: '' })
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} />
|
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} />
|
||||||
@@ -240,7 +356,7 @@ export function ContactSection() {
|
|||||||
|
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label htmlFor="contact-note" className="block text-sm font-medium text-ivory/90">
|
<label htmlFor="contact-note" className="block text-sm font-medium text-ivory/90">
|
||||||
<InlineEdit value={content.note_label} onSave={(v) => saveContent('note_label', v)} /> <span className="font-light text-ivory/50">{content.note_optional_label}</span>
|
<InlineEdit value={content.note_label} onSave={(v) => saveContent('note_label', v)} /> <span className="font-light text-ivory/50">{cleanOptionalLabel}</span>
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="contact-note"
|
id="contact-note"
|
||||||
@@ -273,6 +389,7 @@ export function ContactSection() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { ArrowUpIcon, InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react';
|
import { ArrowUpIcon, CheckIcon, InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react';
|
||||||
import { useSite } from '../api';
|
import { useSite } from '../api';
|
||||||
import { useLang } from '../i18n/lang';
|
import { useLang } from '../i18n/lang';
|
||||||
import { useFieldSaver } from '../admin/AdminContext';
|
import { useFieldSaver } from '../admin/AdminContext';
|
||||||
@@ -11,12 +11,14 @@ const SOCIAL_ICONS: Record<string, React.ComponentType<{ className?: string }>>
|
|||||||
instagram: InstagramIcon,
|
instagram: InstagramIcon,
|
||||||
linkedin: LinkedinIcon,
|
linkedin: LinkedinIcon,
|
||||||
telegram: SendIcon,
|
telegram: SendIcon,
|
||||||
|
bale: CheckIcon,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
const { brand, services, nav, footer } = useSite();
|
const { brand, services, nav, footer } = useSite();
|
||||||
const { t, href } = useLang();
|
const { t, href } = useLang();
|
||||||
const save = useFieldSaver();
|
const save = useFieldSaver();
|
||||||
|
const [openSection, setOpenSection] = useState<string | null>(null);
|
||||||
|
|
||||||
const saveFooter = (field: string, value: string) =>
|
const saveFooter = (field: string, value: string) =>
|
||||||
save('footer', footer.id, { [field]: value }, (d) => {
|
save('footer', footer.id, { [field]: value }, (d) => {
|
||||||
@@ -34,19 +36,22 @@ export function Footer() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="relative border-t border-white/10 bg-char/95 backdrop-blur-2xl">
|
<footer className="relative w-full max-w-full overflow-x-clip border-t border-white/10 bg-char/95 backdrop-blur-2xl">
|
||||||
<div className="mx-auto max-w-site px-6 py-16 lg:px-10">
|
<div className="mx-auto max-w-site px-4 py-8 md:px-6 md:py-16 lg:px-10">
|
||||||
<div className="grid gap-12 lg:grid-cols-12">
|
|
||||||
<div className="lg:col-span-4">
|
{/* Mobile View: Collapsible Luxury Accordions */}
|
||||||
|
<div className="block md:hidden">
|
||||||
|
{/* Top Brand Block */}
|
||||||
|
<div className="mb-6 flex flex-col items-center text-center">
|
||||||
<div className="relative w-fit">
|
<div className="relative w-fit">
|
||||||
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" />
|
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" loading="lazy" decoding="async" />
|
||||||
<SingletonPill resource="brand" label="ویرایش برند، لوگو و تماس" className="absolute top-1/2 -translate-y-1/2 ltr:-right-10 rtl:-left-10" />
|
<SingletonPill resource="brand" label="ویرایش برند، لوگو و تماس" className="absolute top-1/2 -translate-y-1/2 ltr:-right-10 rtl:-left-10" />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-6 max-w-sm text-sm font-light leading-8 text-ivory/60">
|
<p className="mt-4 text-xs font-light leading-relaxed text-ivory/60 max-w-xs">
|
||||||
<InlineEdit multiline value={footer.about_text} onSave={(v) => saveFooter('about_text', v)} />
|
{footer.about_text}
|
||||||
</p>
|
</p>
|
||||||
{/* آیکونهای شبکههای اجتماعی: گویهای شیشهای شناور */}
|
{/* Social icons */}
|
||||||
<div className="mt-8 flex items-center gap-3">
|
<div className="mt-4 flex items-center justify-center gap-3">
|
||||||
{footer.socials.map(({ label, href, icon }) => {
|
{footer.socials.map(({ label, href, icon }) => {
|
||||||
const Icon = SOCIAL_ICONS[icon] ?? InstagramIcon;
|
const Icon = SOCIAL_ICONS[icon] ?? InstagramIcon;
|
||||||
return (
|
return (
|
||||||
@@ -54,100 +59,234 @@ export function Footer() {
|
|||||||
key={label}
|
key={label}
|
||||||
href={href}
|
href={href}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
className="group flex h-11 w-11 items-center justify-center rounded-full border border-white/15 bg-white/[0.04] text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_4px_12px_rgba(0,0,0,0.3)] transition-all duration-300 ease-lux hover:-translate-y-1 hover:border-copper/70 hover:bg-copper/10 hover:text-copper hover:shadow-[0_0_20px_rgba(197,143,92,0.4)] active:scale-95"
|
className="group flex h-10 w-10 items-center justify-center rounded-full border border-white/15 bg-white/[0.04] text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_4px_12px_rgba(0,0,0,0.3)] transition-all duration-300 ease-lux hover:border-copper/70 hover:bg-copper/10 hover:text-copper active:scale-95"
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4 transition-transform duration-300 group-hover:scale-110" />
|
<Icon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:scale-110" />
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-2">
|
{/* Collapsible Link Groups */}
|
||||||
<h3 className="text-sm font-bold text-copper">
|
<div className="border-t border-white/10">
|
||||||
<InlineEdit value={footer.pages_heading} onSave={(v) => saveFooter('pages_heading', v)} />
|
{/* Pages Accordion */}
|
||||||
</h3>
|
<div className="border-b border-white/10">
|
||||||
<ul className="mt-5 space-y-3">
|
<button
|
||||||
{nav.map((item) => (
|
type="button"
|
||||||
<EditableItem key={item.id} resource="nav" item={item}>
|
onClick={() => setOpenSection(openSection === 'pages' ? null : 'pages')}
|
||||||
<li>
|
className="flex w-full items-center justify-between py-3.5 text-xs font-bold text-copper text-start cursor-pointer"
|
||||||
<Link
|
>
|
||||||
to={href(item.href)}
|
<span>{footer.pages_heading}</span>
|
||||||
className="inline-block text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper"
|
<span className={`text-[10px] text-copper transition-transform duration-300 ${openSection === 'pages' ? 'rotate-180' : ''}`}>
|
||||||
>
|
▼
|
||||||
<InlineEdit
|
</span>
|
||||||
value={item.label}
|
</button>
|
||||||
onSave={(v) => saveNav(item.id, { label: v })}
|
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${openSection === 'pages' ? 'max-h-96 pb-3 opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||||
/>
|
<ul className="space-y-2 rtl:pr-2 ltr:pl-2 text-xs text-ivory/70">
|
||||||
</Link>
|
{nav.map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
<Link
|
||||||
|
to={href(item.href)}
|
||||||
|
className="block py-1 transition-colors hover:text-copper"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Services Accordion */}
|
||||||
|
<div className="border-b border-white/10">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpenSection(openSection === 'services' ? null : 'services')}
|
||||||
|
className="flex w-full items-center justify-between py-3.5 text-xs font-bold text-copper text-start cursor-pointer"
|
||||||
|
>
|
||||||
|
<span>{footer.services_heading}</span>
|
||||||
|
<span className={`text-[10px] text-copper transition-transform duration-300 ${openSection === 'services' ? 'rotate-180' : ''}`}>
|
||||||
|
▼
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${openSection === 'services' ? 'max-h-96 pb-3 opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||||
|
<ul className="space-y-2 rtl:pr-2 ltr:pl-2 text-xs text-ivory/70">
|
||||||
|
{services.map((service) => (
|
||||||
|
<li key={service.id} className="py-1">
|
||||||
|
{service.title}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Accordion */}
|
||||||
|
<div className="border-b border-white/10">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpenSection(openSection === 'contact' ? null : 'contact')}
|
||||||
|
className="flex w-full items-center justify-between py-3.5 text-xs font-bold text-copper text-start cursor-pointer"
|
||||||
|
>
|
||||||
|
<span>{footer.contact_heading}</span>
|
||||||
|
<span className={`text-[10px] text-copper transition-transform duration-300 ${openSection === 'contact' ? 'rotate-180' : ''}`}>
|
||||||
|
▼
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${openSection === 'contact' ? 'max-h-96 pb-3 opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||||
|
<ul className="space-y-3 rtl:pr-2 ltr:pl-2 text-xs text-ivory/70">
|
||||||
|
<li className="flex items-start gap-2.5">
|
||||||
|
<MapPinIcon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-copper" />
|
||||||
|
<span className="leading-relaxed">{brand.address}</span>
|
||||||
</li>
|
</li>
|
||||||
</EditableItem>
|
<li className="flex items-center gap-2.5">
|
||||||
))}
|
<PhoneIcon className="h-3.5 w-3.5 shrink-0 text-copper" />
|
||||||
</ul>
|
<a href={brand.phone_href} dir="ltr" className="transition-colors hover:text-copper">
|
||||||
<div className="mt-4">
|
{brand.phone}
|
||||||
<AddItemButton resource="nav" />
|
</a>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2.5">
|
||||||
|
<MailIcon className="h-3.5 w-3.5 shrink-0 text-copper" />
|
||||||
|
<a href={`mailto:${brand.email}`} dir="ltr" className="transition-colors hover:text-copper">
|
||||||
|
{brand.email}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-3">
|
{/* Bottom Copyright & Meta for Mobile */}
|
||||||
<h3 className="text-sm font-bold text-copper">
|
<div className="mt-6 flex flex-col items-center gap-3 border-t border-white/10 pt-4 text-[11px] text-ivory/50 text-center">
|
||||||
<InlineEdit value={footer.services_heading} onSave={(v) => saveFooter('services_heading', v)} />
|
<button
|
||||||
</h3>
|
type="button"
|
||||||
<ul className="mt-5 space-y-3">
|
onClick={scrollToTop}
|
||||||
{services.map((service) => (
|
className="group flex items-center gap-2 rounded-full border border-white/15 bg-white/[0.04] px-3.5 py-1.5 text-xs font-medium text-ivory/70 backdrop-blur-xl transition-all hover:border-copper hover:bg-copper/10 hover:text-copper active:scale-95 cursor-pointer mb-1"
|
||||||
<li key={service.id} className="text-sm font-light text-ivory/60">
|
>
|
||||||
{service.title}
|
<span>{t('backToTop')}</span>
|
||||||
|
<ArrowUpIcon className="h-3 w-3 text-copper transition-transform duration-300 group-hover:-translate-y-0.5" />
|
||||||
|
</button>
|
||||||
|
<p>{footer.copyright}</p>
|
||||||
|
<p className="text-[10px] text-ivory/40">{footer.tagline}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop View: Multi-Column Layout */}
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<div className="grid gap-12 lg:grid-cols-12">
|
||||||
|
<div className="lg:col-span-4">
|
||||||
|
<div className="relative w-fit">
|
||||||
|
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" />
|
||||||
|
<SingletonPill resource="brand" label="ویرایش برند، لوگو و تماس" className="absolute top-1/2 -translate-y-1/2 ltr:-right-10 rtl:-left-10" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-6 max-w-sm text-sm font-light leading-8 text-ivory/60">
|
||||||
|
<InlineEdit multiline value={footer.about_text} onSave={(v) => saveFooter('about_text', v)} />
|
||||||
|
</p>
|
||||||
|
{/* آیکونهای شبکههای اجتماعی */}
|
||||||
|
<div className="mt-8 flex items-center gap-3">
|
||||||
|
{footer.socials.map(({ label, href, icon }) => {
|
||||||
|
const Icon = SOCIAL_ICONS[icon] ?? InstagramIcon;
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={label}
|
||||||
|
href={href}
|
||||||
|
aria-label={label}
|
||||||
|
className="group flex h-11 w-11 items-center justify-center rounded-full border border-white/15 bg-white/[0.04] text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_4px_12px_rgba(0,0,0,0.3)] transition-all duration-300 ease-lux hover:-translate-y-1 hover:border-copper/70 hover:bg-copper/10 hover:text-copper hover:shadow-[0_0_20px_rgba(197,143,92,0.4)] active:scale-95"
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 transition-transform duration-300 group-hover:scale-110" />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<h3 className="text-sm font-bold text-copper">
|
||||||
|
<InlineEdit value={footer.pages_heading} onSave={(v) => saveFooter('pages_heading', v)} />
|
||||||
|
</h3>
|
||||||
|
<ul className="mt-5 space-y-3">
|
||||||
|
{nav.map((item) => (
|
||||||
|
<EditableItem key={item.id} resource="nav" item={item}>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
to={href(item.href)}
|
||||||
|
className="inline-block text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper"
|
||||||
|
>
|
||||||
|
<InlineEdit
|
||||||
|
value={item.label}
|
||||||
|
onSave={(v) => saveNav(item.id, { label: v })}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</EditableItem>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="mt-4">
|
||||||
|
<AddItemButton resource="nav" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<h3 className="text-sm font-bold text-copper">
|
||||||
|
<InlineEdit value={footer.services_heading} onSave={(v) => saveFooter('services_heading', v)} />
|
||||||
|
</h3>
|
||||||
|
<ul className="mt-5 space-y-3">
|
||||||
|
{services.map((service) => (
|
||||||
|
<li key={service.id} className="text-sm font-light text-ivory/60">
|
||||||
|
{service.title}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<h3 className="text-sm font-bold text-copper">
|
||||||
|
<InlineEdit value={footer.contact_heading} onSave={(v) => saveFooter('contact_heading', v)} />
|
||||||
|
</h3>
|
||||||
|
<ul className="mt-5 space-y-4 text-sm font-light text-ivory/60">
|
||||||
|
<li className="flex items-start gap-3">
|
||||||
|
<MapPinIcon className="mt-1 h-4 w-4 shrink-0 text-copper" />
|
||||||
|
<span className="leading-7">{brand.address}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
<li className="flex items-center gap-3">
|
||||||
</ul>
|
<PhoneIcon className="h-4 w-4 shrink-0 text-copper" />
|
||||||
|
<a href={brand.phone_href} dir="ltr" className="transition-colors hover:text-copper">
|
||||||
|
{brand.phone}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-3">
|
||||||
|
<MailIcon className="h-4 w-4 shrink-0 text-copper" />
|
||||||
|
<a href={`mailto:${brand.email}`} dir="ltr" className="transition-colors hover:text-copper">
|
||||||
|
{brand.email}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-3">
|
{/* خط پایانی به همراه دکمه کپسولی بازگشت به بالا */}
|
||||||
<h3 className="text-sm font-bold text-copper">
|
<div className="mt-14 flex flex-col items-center justify-between gap-6 border-t border-white/10 pt-6 text-xs font-light text-ivory/40 md:flex-row">
|
||||||
<InlineEdit value={footer.contact_heading} onSave={(v) => saveFooter('contact_heading', v)} />
|
<p>
|
||||||
</h3>
|
<InlineEdit value={footer.copyright} onSave={(v) => saveFooter('copyright', v)} />
|
||||||
<ul className="mt-5 space-y-4 text-sm font-light text-ivory/60">
|
</p>
|
||||||
<li className="flex items-start gap-3">
|
|
||||||
<MapPinIcon className="mt-1 h-4 w-4 shrink-0 text-copper" />
|
<button
|
||||||
<span className="leading-7">{brand.address}</span>
|
type="button"
|
||||||
</li>
|
onClick={scrollToTop}
|
||||||
<li className="flex items-center gap-3">
|
className="group flex items-center gap-2 rounded-full border border-white/15 bg-white/[0.04] px-4 py-2 text-xs font-medium text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper hover:bg-copper/10 hover:text-copper hover:shadow-[0_0_20px_rgba(197,143,92,0.3)] active:scale-95 cursor-pointer"
|
||||||
<PhoneIcon className="h-4 w-4 shrink-0 text-copper" />
|
>
|
||||||
<a href={brand.phone_href} dir="ltr" className="transition-colors hover:text-copper">
|
<span>{t('backToTop')}</span>
|
||||||
{brand.phone}
|
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-white/10 text-copper transition-transform duration-300 group-hover:-translate-y-0.5 group-hover:bg-copper group-hover:text-char">
|
||||||
</a>
|
<ArrowUpIcon className="h-3 w-3" />
|
||||||
</li>
|
</span>
|
||||||
<li className="flex items-center gap-3">
|
</button>
|
||||||
<MailIcon className="h-4 w-4 shrink-0 text-copper" />
|
|
||||||
<a href={`mailto:${brand.email}`} dir="ltr" className="transition-colors hover:text-copper">
|
<p>
|
||||||
{brand.email}
|
<InlineEdit value={footer.tagline} onSave={(v) => saveFooter('tagline', v)} />
|
||||||
</a>
|
</p>
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* خط پایانی به همراه دکمه کپسولی بازگشت به بالا */}
|
|
||||||
<div className="mt-14 flex flex-col items-center justify-between gap-6 border-t border-white/10 pt-6 text-xs font-light text-ivory/40 md:flex-row">
|
|
||||||
<p>
|
|
||||||
<InlineEdit value={footer.copyright} onSave={(v) => saveFooter('copyright', v)} />
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={scrollToTop}
|
|
||||||
className="group flex items-center gap-2 rounded-full border border-white/15 bg-white/[0.04] px-4 py-2 text-xs font-medium text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper hover:bg-copper/10 hover:text-copper hover:shadow-[0_0_20px_rgba(197,143,92,0.3)] active:scale-95 cursor-pointer"
|
|
||||||
>
|
|
||||||
<span>{t('backToTop')}</span>
|
|
||||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-white/10 text-copper transition-transform duration-300 group-hover:-translate-y-0.5 group-hover:bg-copper group-hover:text-char">
|
|
||||||
<ArrowUpIcon className="h-3 w-3" />
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<InlineEdit value={footer.tagline} onSave={(v) => saveFooter('tagline', v)} />
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ import { ImageField } from './admin/ImageField'
|
|||||||
type PageHeroProps = {
|
type PageHeroProps = {
|
||||||
/** کلید هیرو صفحه در دیتابیس — عنوان/توضیح درجا ویرایشپذیر میشوند */
|
/** کلید هیرو صفحه در دیتابیس — عنوان/توضیح درجا ویرایشپذیر میشوند */
|
||||||
pageKey: string
|
pageKey: string
|
||||||
|
/** تصویر پسزمینه را رندر نکن — صفحه تصویر پیوستهٔ خودش را دارد */
|
||||||
|
hideImage?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageHero({ pageKey }: PageHeroProps) {
|
export function PageHero({ pageKey, hideImage = false }: PageHeroProps) {
|
||||||
const hero = usePageHero(pageKey)
|
const hero = usePageHero(pageKey)
|
||||||
const { t, href } = useLang()
|
const { t, href } = useLang()
|
||||||
const save = useFieldSaver()
|
const save = useFieldSaver()
|
||||||
@@ -27,15 +29,19 @@ export function PageHero({ pageKey }: PageHeroProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative flex h-[58vh] min-h-[420px] items-end overflow-hidden">
|
<section className="relative flex h-[58vh] min-h-[420px] items-end overflow-hidden">
|
||||||
{hero.image && <img src={hero.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />}
|
{hero.image && !hideImage && <img src={hero.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" fetchPriority="high" decoding="async" />}
|
||||||
|
|
||||||
<div className="absolute inset-0 bg-ink/50" aria-hidden="true" />
|
{!hideImage && (
|
||||||
|
<>
|
||||||
|
<div className="absolute inset-0 bg-ink/50" aria-hidden="true" />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0"
|
className="absolute inset-0"
|
||||||
style={{ background: 'linear-gradient(to top, #27221c 6%, rgba(39,34,28,0.1) 70%)' }}
|
style={{ background: 'linear-gradient(to top, #27221c 6%, rgba(39,34,28,0.1) 70%)' }}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
|
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
|
||||||
<nav aria-label={t('breadcrumbAria')} className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
<nav aria-label={t('breadcrumbAria')} className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ export function Preloader() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
const total = 1800
|
// کوتاه — فقط حس برند؛ تأخیر مصنوعی طولانی LCP/INP را خراب میکند
|
||||||
|
const total = 700
|
||||||
const id = window.setInterval(() => {
|
const id = window.setInterval(() => {
|
||||||
const p = Math.min(100, ((Date.now() - start) / total) * 100)
|
const p = Math.min(100, ((Date.now() - start) / total) * 100)
|
||||||
setProgress(p)
|
setProgress(p)
|
||||||
@@ -29,13 +30,6 @@ export function Preloader() {
|
|||||||
return () => window.clearInterval(id)
|
return () => window.clearInterval(id)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.body.style.overflow = done ? '' : 'hidden'
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = ''
|
|
||||||
}
|
|
||||||
}, [done])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{!done && (
|
{!done && (
|
||||||
|
|||||||
@@ -52,31 +52,33 @@ export function ProjectCard({ project, imageUrl, detailHref }: ProjectCardProps)
|
|||||||
<img
|
<img
|
||||||
src={finalImageUrl}
|
src={finalImageUrl}
|
||||||
alt={title}
|
alt={title}
|
||||||
className="w-full h-[250px] object-cover transition-transform duration-700 ease-lux group-hover:scale-110"
|
className="w-full h-44 md:h-[250px] object-cover transition-transform duration-700 ease-lux group-hover:scale-110"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* نشانگر وضعیت کپسولی شیشهای */}
|
{/* نشانگر وضعیت کپسولی شیشهای */}
|
||||||
<div
|
<div
|
||||||
className={`absolute top-4 ltr:left-4 rtl:right-4 flex items-center gap-2 rounded-full border border-white/15 bg-ink/75 px-4 py-1.5 text-xs font-light backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)] ${textColor}`}
|
className={`absolute top-3 ltr:left-3 rtl:right-3 md:top-4 md:ltr:left-4 md:rtl:right-4 flex items-center gap-1.5 md:gap-2 rounded-full border border-white/15 bg-ink/75 px-3 py-1 md:px-4 md:py-1.5 text-xs font-light backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)] ${textColor}`}
|
||||||
>
|
>
|
||||||
<span className={`h-2 w-2 rounded-full ${dotColor} ${isOngoing ? 'shadow-[0_0_10px_rgba(197,143,92,0.9)]' : ''}`} />
|
<span className={`h-2 w-2 rounded-full ${dotColor} ${isOngoing ? 'shadow-[0_0_10px_rgba(197,143,92,0.9)]' : ''}`} />
|
||||||
{statusText}
|
{statusText}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col p-7 pb-4">
|
<div className="flex flex-1 flex-col p-4 pb-3 md:p-7 md:pb-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="font-pinar text-xl font-bold text-ivory transition-colors duration-300 group-hover:text-copper">
|
<h3 className="font-pinar text-lg font-bold text-ivory transition-colors duration-300 group-hover:text-copper md:text-xl">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
{location && <p className="mt-4 text-sm font-light text-ivory/60">{location}</p>}
|
{location && <p className="mt-2 text-xs font-light text-ivory/60 md:mt-4 md:text-sm">{location}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{area && (
|
{area && (
|
||||||
<>
|
<>
|
||||||
<div className="my-6 h-px w-full bg-white/10" />
|
<div className="my-3 h-px w-full bg-white/10 md:my-6" />
|
||||||
<div className="mt-auto flex items-center justify-end text-sm font-light text-ivory/60">
|
<div className="mt-auto flex items-center justify-end text-xs font-light text-ivory/60 md:text-sm">
|
||||||
{/* واحد زیربنا در محتوای انگلیسی داخل خود مقدار است — فقط در فارسی اضافه میشود */}
|
{/* واحد زیربنا در محتوای انگلیسی داخل خود مقدار است — فقط در فارسی اضافه میشود */}
|
||||||
<span>{lang === 'fa' ? `${area} ${t('sqm')}` : area}</span>
|
<span>{lang === 'fa' ? `${area} ${t('sqm')}` : area}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,7 +88,7 @@ export function ProjectCard({ project, imageUrl, detailHref }: ProjectCardProps)
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* پایهٔ کارت: یک دکمه واحد */}
|
{/* پایهٔ کارت: یک دکمه واحد */}
|
||||||
<div className="flex flex-col gap-2 px-7 pb-7">
|
<div className="flex flex-col gap-2 px-4 pb-4 md:px-7 md:pb-7">
|
||||||
<PremiumButton to={finalDetailHref} className="w-full">
|
<PremiumButton to={finalDetailHref} className="w-full">
|
||||||
{t('viewProject')}
|
{t('viewProject')}
|
||||||
</PremiumButton>
|
</PremiumButton>
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { EditorContent, useEditor } from '@tiptap/react'
|
import { EditorContent, useEditor } from '@tiptap/react'
|
||||||
import StarterKit from '@tiptap/starter-kit'
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
|
import { Table } from '@tiptap/extension-table'
|
||||||
|
import TableRow from '@tiptap/extension-table-row'
|
||||||
|
import TableHeader from '@tiptap/extension-table-header'
|
||||||
|
import TableCell from '@tiptap/extension-table-cell'
|
||||||
import Link from '@tiptap/extension-link'
|
import Link from '@tiptap/extension-link'
|
||||||
import { Mark, mergeAttributes } from '@tiptap/core'
|
import { Mark, mergeAttributes } from '@tiptap/core'
|
||||||
import {
|
import {
|
||||||
AlignLeftIcon,
|
AlignLeftIcon,
|
||||||
AlignRightIcon,
|
AlignRightIcon,
|
||||||
|
Columns2Icon,
|
||||||
|
Rows2Icon,
|
||||||
|
PanelTopIcon,
|
||||||
BoldIcon,
|
BoldIcon,
|
||||||
Heading1Icon,
|
Heading1Icon,
|
||||||
Heading2Icon,
|
Heading2Icon,
|
||||||
@@ -20,6 +27,7 @@ import {
|
|||||||
QuoteIcon,
|
QuoteIcon,
|
||||||
Redo2Icon,
|
Redo2Icon,
|
||||||
StrikethroughIcon,
|
StrikethroughIcon,
|
||||||
|
TableIcon,
|
||||||
UnderlineIcon,
|
UnderlineIcon,
|
||||||
Undo2Icon,
|
Undo2Icon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
@@ -60,6 +68,10 @@ export const ARTICLE_EXTENSIONS = [
|
|||||||
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
|
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
|
||||||
isAllowedUri: (uri: string) => isAllowedHref(uri),
|
isAllowedUri: (uri: string) => isAllowedHref(uri),
|
||||||
}),
|
}),
|
||||||
|
Table.configure({ resizable: true }),
|
||||||
|
TableRow,
|
||||||
|
TableHeader,
|
||||||
|
TableCell,
|
||||||
LegacySmallSizeMark,
|
LegacySmallSizeMark,
|
||||||
EnglishText,
|
EnglishText,
|
||||||
BidiTypography,
|
BidiTypography,
|
||||||
@@ -73,6 +85,8 @@ type ArticleEditorProps = {
|
|||||||
* (مثلاً وقتی مقاله از سرور میرسد). بدون این prop محتوا فقط یکبار مقداردهی میشود.
|
* (مثلاً وقتی مقاله از سرور میرسد). بدون این prop محتوا فقط یکبار مقداردهی میشود.
|
||||||
*/
|
*/
|
||||||
contentKey?: string | number
|
contentKey?: string | number
|
||||||
|
/** زبان محتوای ویرایشگر — جهت و زبان عنصر ویرایش با آن همگام میشود */
|
||||||
|
contentLang?: 'fa' | 'en'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** نمونه ویرایشگر TipTap — همان چیزی که useEditor برمیگرداند */
|
/** نمونه ویرایشگر TipTap — همان چیزی که useEditor برمیگرداند */
|
||||||
@@ -174,7 +188,7 @@ const Divider = () => <span className="mx-1 h-5 w-px bg-ivory/10" aria-hidden="t
|
|||||||
* ویرایشگر WYSIWYG مقاله (TipTap) — نود تصویر کپشندار، مودال لینک سئو-محور،
|
* ویرایشگر WYSIWYG مقاله (TipTap) — نود تصویر کپشندار، مودال لینک سئو-محور،
|
||||||
* کلید راستچین/چپچین (فقط روی متن اثر دارد، نه نوار ابزار) و آپلود تصویر به MinIO.
|
* کلید راستچین/چپچین (فقط روی متن اثر دارد، نه نوار ابزار) و آپلود تصویر به MinIO.
|
||||||
*/
|
*/
|
||||||
export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps) {
|
export function ArticleEditor({ doc, onChange, contentKey, contentLang = 'fa' }: ArticleEditorProps) {
|
||||||
const { runSave } = useAdmin()
|
const { runSave } = useAdmin()
|
||||||
const fileRef = useRef<HTMLInputElement>(null)
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
@@ -187,10 +201,13 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
|
|||||||
extensions: ARTICLE_EXTENSIONS,
|
extensions: ARTICLE_EXTENSIONS,
|
||||||
content: doc && Object.keys(doc).length ? doc : undefined,
|
content: doc && Object.keys(doc).length ? doc : undefined,
|
||||||
immediatelyRender: false,
|
immediatelyRender: false,
|
||||||
|
// نوار ابزار فقط با رندر دوباره روی هر تراکنش زنده میماند (پیشفرض v3 خاموش است)
|
||||||
|
shouldRerenderOnTransaction: true,
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
dir: 'rtl',
|
dir: 'rtl',
|
||||||
lang: 'fa',
|
lang: 'fa',
|
||||||
|
'data-base-dir': 'rtl',
|
||||||
class: 'article-body min-h-[300px] px-4 py-4 focus:outline-none',
|
class: 'article-body min-h-[300px] px-4 py-4 focus:outline-none',
|
||||||
'data-wysiwyg': 'true',
|
'data-wysiwyg': 'true',
|
||||||
},
|
},
|
||||||
@@ -279,6 +296,11 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
|
|||||||
onUpdate: ({ editor: instance }) => onChange(instance.getJSON()),
|
onUpdate: ({ editor: instance }) => onChange(instance.getJSON()),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// جهت متن از زبان محتوای در حال ویرایش پیروی میکند — انگلیسی LTR، فارسی RTL
|
||||||
|
useEffect(() => {
|
||||||
|
setDirection(contentLang === 'en' ? 'ltr' : 'rtl')
|
||||||
|
}, [contentLang])
|
||||||
|
|
||||||
// جهت متن فقط روی ناحیه ویرایش اعمال میشود؛ نوار ابزار همیشه راستچین میماند
|
// جهت متن فقط روی ناحیه ویرایش اعمال میشود؛ نوار ابزار همیشه راستچین میماند
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
@@ -429,6 +451,45 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
|
|||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
|
<ToolButton
|
||||||
|
label="درج جدول"
|
||||||
|
active={editor.isActive('table')}
|
||||||
|
onClick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()}
|
||||||
|
>
|
||||||
|
<TableIcon className="h-4 w-4" />
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton label="حذف جدول" onClick={() => editor.chain().focus().deleteTable().run()}>
|
||||||
|
<TableIcon className="h-4 w-4 rotate-45" />
|
||||||
|
</ToolButton>
|
||||||
|
{editor.isActive('table') && (
|
||||||
|
<>
|
||||||
|
<ToolButton label="ستون قبل" onClick={() => editor.chain().focus().addColumnBefore().run()}>
|
||||||
|
<Columns2Icon className="h-4 w-4" />
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton label="ستون بعد" onClick={() => editor.chain().focus().addColumnAfter().run()}>
|
||||||
|
<Columns2Icon className="h-4 w-4 rotate-180" />
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton label="حذف ستون" onClick={() => editor.chain().focus().deleteColumn().run()}>
|
||||||
|
<Columns2Icon className="h-4 w-4 opacity-60" />
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton label="ردیف بالا" onClick={() => editor.chain().focus().addRowBefore().run()}>
|
||||||
|
<Rows2Icon className="h-4 w-4" />
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton label="ردیف پایین" onClick={() => editor.chain().focus().addRowAfter().run()}>
|
||||||
|
<Rows2Icon className="h-4 w-4 rotate-180" />
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton label="حذف ردیف" onClick={() => editor.chain().focus().deleteRow().run()}>
|
||||||
|
<Rows2Icon className="h-4 w-4 opacity-60" />
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton
|
||||||
|
label="سرستون"
|
||||||
|
active={editor.isActive('table')}
|
||||||
|
onClick={() => editor.chain().focus().toggleHeaderRow().run()}
|
||||||
|
>
|
||||||
|
<PanelTopIcon className="h-4 w-4" />
|
||||||
|
</ToolButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<ToolButton label="بولد" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
|
<ToolButton label="بولد" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
|
||||||
<BoldIcon className="h-4 w-4" />
|
<BoldIcon className="h-4 w-4" />
|
||||||
</ToolButton>
|
</ToolButton>
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ export const ArticleImage = Image.extend({
|
|||||||
parseHTML: (element) =>
|
parseHTML: (element) =>
|
||||||
element.closest('figure')?.querySelector('figcaption')?.textContent?.trim() ?? '',
|
element.closest('figure')?.querySelector('figcaption')?.textContent?.trim() ?? '',
|
||||||
},
|
},
|
||||||
|
// پیوند کپشن — کل متن figcaption به این نشانی لینک میشود
|
||||||
|
caption_href: {
|
||||||
|
default: '',
|
||||||
|
parseHTML: (element) =>
|
||||||
|
element.closest('figure')?.querySelector('figcaption a')?.getAttribute('href') ?? '',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -35,6 +41,7 @@ export const ArticleImage = Image.extend({
|
|||||||
alt: img.getAttribute('alt'),
|
alt: img.getAttribute('alt'),
|
||||||
title: img.getAttribute('title'),
|
title: img.getAttribute('title'),
|
||||||
caption: element.querySelector('figcaption')?.textContent?.trim() ?? '',
|
caption: element.querySelector('figcaption')?.textContent?.trim() ?? '',
|
||||||
|
caption_href: element.querySelector('figcaption a')?.getAttribute('href') ?? '',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -51,12 +58,18 @@ export const ArticleImage = Image.extend({
|
|||||||
]
|
]
|
||||||
|
|
||||||
// کپشن خالی → عنصر figcaption اصلاً رندر نمیشود (فقط تصویر میماند)
|
// کپشن خالی → عنصر figcaption اصلاً رندر نمیشود (فقط تصویر میماند)
|
||||||
|
const captionHref = (HTMLAttributes.caption_href as string | undefined)?.trim() ?? ''
|
||||||
|
// توجه: آرایهٔ درونی بهصورت تو در تو پاس میشود — اسپرد آن اسپک را تخت میکند
|
||||||
|
// و رندرر استاتیک با «Unsupported DomOutputSpec» میمیرد.
|
||||||
|
const captionSpec = captionHref
|
||||||
|
? ['a', { href: captionHref, target: '_blank', rel: 'noopener noreferrer nofollow' }, caption]
|
||||||
|
: caption
|
||||||
return caption
|
return caption
|
||||||
? [
|
? [
|
||||||
'figure',
|
'figure',
|
||||||
{ class: 'article-figure' },
|
{ class: 'article-figure' },
|
||||||
image,
|
image,
|
||||||
['figcaption', { class: 'article-figure__caption' }, caption],
|
['figcaption', { class: 'article-figure__caption' }, captionSpec],
|
||||||
]
|
]
|
||||||
: ['figure', { class: 'article-figure' }, image]
|
: ['figure', { class: 'article-figure' }, image]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type ImageAttrs = {
|
|||||||
alt: string | null
|
alt: string | null
|
||||||
title: string | null
|
title: string | null
|
||||||
caption: string | null
|
caption: string | null
|
||||||
|
caption_href?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const DELETE_BUTTON_CLASS =
|
const DELETE_BUTTON_CLASS =
|
||||||
@@ -18,6 +19,35 @@ const DELETE_BUTTON_CLASS =
|
|||||||
const INLINE_FIELD_CLASS =
|
const INLINE_FIELD_CLASS =
|
||||||
'w-full border border-ivory/15 bg-ink px-3 py-1.5 text-xs font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
|
'w-full border border-ivory/15 bg-ink px-3 py-1.5 text-xs font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* فیلد متصل به attrs گره — چون مقدار جدید از پروسسمیرور با یک رندر تأخیر
|
||||||
|
* میرسد، ورودی کنترلشدهٔ مستقیم کرسر را به آخر متن میاندازد. اینجا مقدار
|
||||||
|
* محلی نگه داشته میشود و فقط وقتی ورودی فوکوس ندارد از بیرون همگام میشود.
|
||||||
|
*/
|
||||||
|
function useAttrField(
|
||||||
|
attrValue: string,
|
||||||
|
commit: (value: string) => void,
|
||||||
|
externalRef?: React.MutableRefObject<HTMLInputElement | null>,
|
||||||
|
) {
|
||||||
|
const ownRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
const ref = externalRef ?? ownRef
|
||||||
|
const [local, setLocal] = useState(attrValue)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (document.activeElement !== ref.current) setLocal(attrValue)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [attrValue])
|
||||||
|
|
||||||
|
return {
|
||||||
|
ref,
|
||||||
|
value: local,
|
||||||
|
onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setLocal(event.target.value)
|
||||||
|
commit(event.target.value)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const ADD_CAPTION_CLASS =
|
const ADD_CAPTION_CLASS =
|
||||||
'mt-1.5 inline-flex items-center gap-1.5 border border-dashed border-ivory/25 px-3 py-1 text-[11px] font-light text-ivory/50 transition-colors duration-150 hover:border-copper hover:text-copper'
|
'mt-1.5 inline-flex items-center gap-1.5 border border-dashed border-ivory/25 px-3 py-1 text-[11px] font-light text-ivory/50 transition-colors duration-150 hover:border-copper hover:text-copper'
|
||||||
|
|
||||||
@@ -45,12 +75,16 @@ function isExternalImage(src: string): boolean {
|
|||||||
* - دکمه X روی تصویر کل تصویر را حذف میکند.
|
* - دکمه X روی تصویر کل تصویر را حذف میکند.
|
||||||
*/
|
*/
|
||||||
export function ImageNodeView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
|
export function ImageNodeView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
|
||||||
const { src, alt, title, caption } = node.attrs as ImageAttrs
|
const { src, alt, title, caption, caption_href: captionHref } = node.attrs as ImageAttrs & { caption_href?: string | null }
|
||||||
const { runSave } = useAdmin()
|
const { runSave } = useAdmin()
|
||||||
const hasCaption = Boolean((caption ?? '').trim())
|
const hasCaption = Boolean((caption ?? '').trim())
|
||||||
const [captionOpen, setCaptionOpen] = useState(true)
|
const [captionOpen, setCaptionOpen] = useState(true)
|
||||||
const [rehosting, setRehosting] = useState(false)
|
const [rehosting, setRehosting] = useState(false)
|
||||||
const captionInputRef = useRef<HTMLInputElement>(null)
|
const captionInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const altField = useAttrField(alt ?? '', (v) => updateAttributes({ alt: v }))
|
||||||
|
const titleField = useAttrField(title ?? '', (v) => updateAttributes({ title: v }))
|
||||||
|
const captionField = useAttrField(caption ?? '', (v) => updateAttributes({ caption: v }), captionInputRef)
|
||||||
|
const captionHrefField = useAttrField(captionHref ?? '', (v) => updateAttributes({ caption_href: v }))
|
||||||
const focusOnOpen = useRef(false)
|
const focusOnOpen = useRef(false)
|
||||||
const showCaption = captionOpen || hasCaption
|
const showCaption = captionOpen || hasCaption
|
||||||
const external = isExternalImage(src)
|
const external = isExternalImage(src)
|
||||||
@@ -64,7 +98,7 @@ export function ImageNodeView({ node, updateAttributes, deleteNode, selected }:
|
|||||||
|
|
||||||
/** حذف کامل کپشن: عنصر figcaption و ورودیاش از DOM ویرایشگر بیرون میرود */
|
/** حذف کامل کپشن: عنصر figcaption و ورودیاش از DOM ویرایشگر بیرون میرود */
|
||||||
const removeCaption = () => {
|
const removeCaption = () => {
|
||||||
updateAttributes({ caption: '' })
|
updateAttributes({ caption: '', caption_href: '' })
|
||||||
setCaptionOpen(false)
|
setCaptionOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,16 +157,14 @@ export function ImageNodeView({ node, updateAttributes, deleteNode, selected }:
|
|||||||
<div contentEditable={false} className="mt-1.5 grid gap-1.5 sm:grid-cols-2">
|
<div contentEditable={false} className="mt-1.5 grid gap-1.5 sm:grid-cols-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={alt ?? ''}
|
{...altField}
|
||||||
onChange={(event) => updateAttributes({ alt: event.target.value })}
|
|
||||||
placeholder="نام تصویر / متن جایگزین (Alt)…"
|
placeholder="نام تصویر / متن جایگزین (Alt)…"
|
||||||
aria-label="متن جایگزین تصویر"
|
aria-label="متن جایگزین تصویر"
|
||||||
className={INLINE_FIELD_CLASS}
|
className={INLINE_FIELD_CLASS}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={title ?? ''}
|
{...titleField}
|
||||||
onChange={(event) => updateAttributes({ title: event.target.value })}
|
|
||||||
placeholder="عنوان تصویر (Title)…"
|
placeholder="عنوان تصویر (Title)…"
|
||||||
aria-label="عنوان تصویر"
|
aria-label="عنوان تصویر"
|
||||||
className={INLINE_FIELD_CLASS}
|
className={INLINE_FIELD_CLASS}
|
||||||
@@ -142,14 +174,20 @@ export function ImageNodeView({ node, updateAttributes, deleteNode, selected }:
|
|||||||
{showCaption ? (
|
{showCaption ? (
|
||||||
<figcaption className="article-figure__caption" contentEditable={false}>
|
<figcaption className="article-figure__caption" contentEditable={false}>
|
||||||
<input
|
<input
|
||||||
ref={captionInputRef}
|
|
||||||
type="text"
|
type="text"
|
||||||
value={caption ?? ''}
|
{...captionField}
|
||||||
onChange={(event) => updateAttributes({ caption: event.target.value })}
|
|
||||||
placeholder="توضیح تصویر (کپشن)…"
|
placeholder="توضیح تصویر (کپشن)…"
|
||||||
aria-label="کپشن تصویر"
|
aria-label="کپشن تصویر"
|
||||||
className={INLINE_FIELD_CLASS}
|
className={INLINE_FIELD_CLASS}
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
{...captionHrefField}
|
||||||
|
placeholder="لینک کپشن (اختیاری) — مثل منبع عکس…"
|
||||||
|
aria-label="لینک کپشن تصویر"
|
||||||
|
dir="ltr"
|
||||||
|
className={INLINE_FIELD_CLASS}
|
||||||
|
/>
|
||||||
{/* حذف کامل کپشن — تصویر دستنخورده میماند */}
|
{/* حذف کامل کپشن — تصویر دستنخورده میماند */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export type SeoInput = {
|
|||||||
excerpt: string
|
excerpt: string
|
||||||
body: ArticleBodyDoc | string[]
|
body: ArticleBodyDoc | string[]
|
||||||
keyphrase: string
|
keyphrase: string
|
||||||
|
/** نام نویسنده — سیگنال E-E-A-T و اسکیمای Article */
|
||||||
|
author?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Status = 'good' | 'ok' | 'bad'
|
type Status = 'good' | 'ok' | 'bad'
|
||||||
@@ -21,7 +23,9 @@ export function useSeoChecks(input: SeoInput): Check[] {
|
|||||||
const text = docToText(input.body)
|
const text = docToText(input.body)
|
||||||
const words = text.split(/\s+/).filter(Boolean)
|
const words = text.split(/\s+/).filter(Boolean)
|
||||||
const kp = input.keyphrase.trim()
|
const kp = input.keyphrase.trim()
|
||||||
const has = (s: string, needle: string) => s.includes(needle)
|
// تطبیق کلمه کلیدی بیتوجه به بزرگ/کوچکی حروف — و نرمالسازی عربی/فارسی
|
||||||
|
const norm = (s: string) => s.toLowerCase().replace(/ي/g, 'ی').replace(/ك/g, 'ک')
|
||||||
|
const has = (s: string, needle: string) => norm(s).includes(norm(needle))
|
||||||
const checks: Check[] = []
|
const checks: Check[] = []
|
||||||
|
|
||||||
// عنوان
|
// عنوان
|
||||||
@@ -95,6 +99,34 @@ export function useSeoChecks(input: SeoInput): Check[] {
|
|||||||
else
|
else
|
||||||
checks.push({ label: 'نشانی (slug)', status: 'good', hint: 'نشانی مناسب است.' })
|
checks.push({ label: 'نشانی (slug)', status: 'good', hint: 'نشانی مناسب است.' })
|
||||||
|
|
||||||
|
// ——— سئوی مولد (GEO) — آمادهسازی برای ارجاع در پاسخهای هوش مصنوعی ———
|
||||||
|
|
||||||
|
// پاسخ مستقیم در ابتدای مقاله — دستیارهای هوشمند بخشهای خودبسندهٔ آغازین را نقل میکنند
|
||||||
|
const firstPWords = firstParagraph(input.body).split(/\s+/).filter(Boolean).length
|
||||||
|
if (firstPWords >= 20)
|
||||||
|
checks.push({ label: 'پاسخ مستقیم در ابتدا (GEO)', status: 'good', hint: 'پاراگراف اول پاسخ نسبتاً کاملی میدهد — دقیقاً چیزی که هوش مصنوعی نقل میکند.' })
|
||||||
|
else
|
||||||
|
checks.push({ label: 'پاسخ مستقیم در ابتدا (GEO)', status: 'ok', hint: 'پاراگراف اول را پاسخوار بنویسید (حدود ۴۰ تا ۶۰ واژه، پاسخ مستقیم پرسش اصلی مقاله).' })
|
||||||
|
|
||||||
|
// آمار و عدد — قویترین سیگنال ارجاع هوش مصنوعی (بههمراه ذکر منبع)
|
||||||
|
const numberTokens = words.filter((w) => /\d|[۰-۹]/.test(w)).length
|
||||||
|
if (numberTokens >= 5)
|
||||||
|
checks.push({ label: `آمار و اعداد (${numberTokens} مورد)`, status: 'good', hint: 'متن دادهمحور است — سیگنال قوی برای استناد.' })
|
||||||
|
else
|
||||||
|
checks.push({ label: 'آمار و اعداد', status: 'ok', hint: 'حداقل ۵ عدد یا درصد (با ذکر منبع) به متن اضافه کنید — محتوای دادهمحور بیش از همه ارجاع میگیرد.' })
|
||||||
|
|
||||||
|
// تیتر پرسشی — همراستا با الگوی پرسش کاربران و «People Also Ask»
|
||||||
|
if (h2s.some((h) => h.includes('؟') || h.includes('?')))
|
||||||
|
checks.push({ label: 'تیتر پرسشی', status: 'good', hint: 'حداقل یک تیتر به شکل پرسش است.' })
|
||||||
|
else
|
||||||
|
checks.push({ label: 'تیتر پرسشی', status: 'ok', hint: 'یک تیتر H2 پرسشی (با «؟») اضافه کنید — همانطور که کاربران میپرسند.' })
|
||||||
|
|
||||||
|
// نویسنده — سیگنال اعتماد و هویت روشن محتوا
|
||||||
|
if ((input.author ?? '').trim())
|
||||||
|
checks.push({ label: 'نویسنده', status: 'good', hint: 'نام نویسنده ثبت شده — در اسکیمای مقاله هم میآید.' })
|
||||||
|
else
|
||||||
|
checks.push({ label: 'نویسنده', status: 'ok', hint: 'نام نویسنده را بنویسید — سیگنال اعتماد (E-E-A-T) و در اسکیمای مقاله استفاده میشود.' })
|
||||||
|
|
||||||
return checks
|
return checks
|
||||||
}, [input])
|
}, [input])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { ArrowLeftIcon } from 'lucide-react';
|
import { ArrowLeftIcon } from 'lucide-react';
|
||||||
import { Reveal } from '../Reveal';
|
import { Reveal } from '../Reveal';
|
||||||
import { ButtonLink } from '../Button';
|
import { ButtonLink } from '../Button';
|
||||||
@@ -8,6 +9,7 @@ import { useLang } from '../../i18n/lang';
|
|||||||
import { useFieldSaver } from '../../admin/AdminContext';
|
import { useFieldSaver } from '../../admin/AdminContext';
|
||||||
import { InlineEdit } from '../admin/InlineEdit';
|
import { InlineEdit } from '../admin/InlineEdit';
|
||||||
import { AddItemButton, EditableItem } from '../../admin/Editable';
|
import { AddItemButton, EditableItem } from '../../admin/Editable';
|
||||||
|
import { formatJalali } from '../../utils/date';
|
||||||
|
|
||||||
export function Articles() {
|
export function Articles() {
|
||||||
const { articles } = useSite();
|
const { articles } = useSite();
|
||||||
@@ -17,6 +19,9 @@ export function Articles() {
|
|||||||
|
|
||||||
// پیشنویسها فقط در صفحه وبلاگ (برای ادمین) دیده میشوند، نه در خانه
|
// پیشنویسها فقط در صفحه وبلاگ (برای ادمین) دیده میشوند، نه در خانه
|
||||||
const published = articles.filter((a) => a.status !== 'draft');
|
const published = articles.filter((a) => a.status !== 'draft');
|
||||||
|
const topArticles = published.slice(0, 3);
|
||||||
|
const spotlight = topArticles[0];
|
||||||
|
const compactList = topArticles.slice(1, 3);
|
||||||
|
|
||||||
const saveHeading = (field: 'label' | 'title' | 'lead' | 'cta_label', value: string) =>
|
const saveHeading = (field: 'label' | 'title' | 'lead' | 'cta_label', value: string) =>
|
||||||
save('section-headings', heading.id, { [field]: value }, (d) => {
|
save('section-headings', heading.id, { [field]: value }, (d) => {
|
||||||
@@ -25,24 +30,97 @@ export function Articles() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-char py-24 lg:py-32" aria-labelledby="articles-title">
|
<section className="w-full max-w-full overflow-x-clip bg-char py-16 md:py-24 lg:py-32" aria-labelledby="articles-title">
|
||||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
<div className="mx-auto max-w-site px-4 md:px-6 lg:px-10">
|
||||||
<Reveal className="max-w-2xl ltr:text-left rtl:text-right">
|
<Reveal className="max-w-2xl ltr:text-left rtl:text-right">
|
||||||
<p className="text-sm font-light text-copper">
|
<p className="text-sm font-light text-copper">
|
||||||
<InlineEdit value={heading.label} onSave={(v) => saveHeading('label', v)} />
|
<InlineEdit value={heading.label} onSave={(v) => saveHeading('label', v)} />
|
||||||
</p>
|
</p>
|
||||||
<h2 id="articles-title" className="mt-5 text-section font-bold text-ivory">
|
<h2 id="articles-title" className="mt-3 md:mt-5 text-section font-bold text-ivory">
|
||||||
<InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} />
|
<InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} />
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-5 text-base font-light leading-9 text-ivory/60">
|
<p className="mt-3 md:mt-5 text-sm md:text-base font-light leading-7 md:leading-9 text-ivory/60">
|
||||||
<InlineEdit multiline value={heading.lead} onSave={(v) => saveHeading('lead', v)} />
|
<InlineEdit multiline value={heading.lead} onSave={(v) => saveHeading('lead', v)} />
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6 h-px w-24 bg-copper/70" />
|
<div className="mt-4 md:mt-6 h-px w-24 bg-copper/70" />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<div className="mt-14 grid gap-6 lg:grid-cols-3">
|
{/* Mobile View: Magazine Editorial layout (1 Featured Spotlight + Compact Rows) */}
|
||||||
{published.slice(0, 3).map((article, i) =>
|
<div className="block md:hidden mt-8">
|
||||||
<Reveal key={article.id} delay={0.06 * i} className="h-full">
|
{/* First Article (Spotlight) */}
|
||||||
|
{spotlight && (
|
||||||
|
<EditableItem resource="articles" item={spotlight} editHref={`/admin/write/${spotlight.id}`}>
|
||||||
|
<Link
|
||||||
|
className="block rounded-xl border border-ivory/10 bg-char/40 overflow-hidden mb-3 group"
|
||||||
|
to={href(`/blog/${spotlight.slug}`)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={spotlight.image}
|
||||||
|
alt={spotlight.title}
|
||||||
|
className="w-full aspect-[16/9] object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
<div className="p-3.5">
|
||||||
|
<div className="flex items-center gap-2 text-[11px] text-copper font-medium mb-1">
|
||||||
|
<span>{spotlight.category}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{formatJalali(spotlight.date)}</span>
|
||||||
|
</div>
|
||||||
|
<h4 className="text-sm font-bold text-ivory leading-snug line-clamp-2 mb-1 group-hover:text-copper transition-colors">
|
||||||
|
{spotlight.title}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-ivory/60 line-clamp-2 leading-relaxed">
|
||||||
|
{spotlight.excerpt}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</EditableItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Remaining Articles (Compact List Rows) */}
|
||||||
|
{compactList.length > 0 && (
|
||||||
|
<div className="divide-y divide-ivory/15 border-t border-b border-ivory/15 mb-4">
|
||||||
|
{compactList.map((article) => (
|
||||||
|
<EditableItem key={article.id} resource="articles" item={article} editHref={`/admin/write/${article.id}`}>
|
||||||
|
<Link
|
||||||
|
className="py-2.5 flex items-center gap-3 group"
|
||||||
|
to={href(`/blog/${article.slug}`)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={article.image}
|
||||||
|
alt={article.title}
|
||||||
|
className="w-14 h-14 rounded-lg object-cover shrink-0 border border-ivory/10"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-[10px] text-copper block mb-0.5">{formatJalali(article.date)}</span>
|
||||||
|
<h5 className="text-xs font-semibold text-ivory line-clamp-2 leading-snug group-hover:text-copper transition-colors">
|
||||||
|
{article.title}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</EditableItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bottom Button ("همه مقالات") */}
|
||||||
|
{heading.cta_label && heading.cta_href && (
|
||||||
|
<Link
|
||||||
|
to={href(heading.cta_href)}
|
||||||
|
className="w-full py-2.5 rounded-xl border border-ivory/20 bg-char/60 hover:bg-copper/10 hover:border-copper/40 text-xs font-semibold text-ivory text-center block transition-all active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
<InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop View: 3-Column Grid */}
|
||||||
|
<div className="hidden md:grid mt-14 grid-cols-3 gap-6">
|
||||||
|
{topArticles.map((article, i) =>
|
||||||
|
<Reveal
|
||||||
|
key={article.id}
|
||||||
|
delay={0.06 * i}
|
||||||
|
className="h-full"
|
||||||
|
>
|
||||||
<EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}>
|
<EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}>
|
||||||
<ArticleCard article={article} />
|
<ArticleCard article={article} />
|
||||||
</EditableItem>
|
</EditableItem>
|
||||||
@@ -64,6 +142,6 @@ export function Articles() {
|
|||||||
<AddItemButton resource="articles" />
|
<AddItemButton resource="articles" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>);
|
</section>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -48,7 +48,7 @@ export function BeforeAfter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-char py-24 lg:py-32" aria-labelledby="ba-title">
|
<section className="bg-ink py-24 lg:py-32" aria-labelledby="ba-title">
|
||||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||||
|
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
@@ -78,6 +78,8 @@ export function BeforeAfter() {
|
|||||||
src={before_after.before}
|
src={before_after.before}
|
||||||
alt={before_after.before_label}
|
alt={before_after.before_label}
|
||||||
className="absolute inset-0 h-full w-full object-cover object-center"
|
className="absolute inset-0 h-full w-full object-cover object-center"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -86,6 +88,8 @@ export function BeforeAfter() {
|
|||||||
src={before_after.after}
|
src={before_after.after}
|
||||||
alt=""
|
alt=""
|
||||||
className="absolute inset-0 h-full w-full object-cover object-center"
|
className="absolute inset-0 h-full w-full object-cover object-center"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ type FaqProps = {
|
|||||||
hideHeading?: boolean;
|
hideHeading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Faq({ hideHeading = false }: FaqProps) {
|
export function Faq({ hideHeading = false, tone = 'gray' }: FaqProps & { tone?: 'black' | 'gray' }) {
|
||||||
const { faqs } = useSite()
|
const { faqs } = useSite()
|
||||||
const { t } = useLang()
|
const { t } = useLang()
|
||||||
const [open, setOpen] = useState<number | null>(0)
|
const [open, setOpen] = useState<number | null>(0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="faq-title">
|
<section className={`${tone === 'black' ? 'bg-char' : 'bg-ink'} py-24 lg:py-32`} aria-labelledby="faq-title">
|
||||||
|
|
||||||
<div className={hideHeading ? "mx-auto max-w-4xl px-6 lg:px-10" : "mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-12 lg:px-10"}>
|
<div className={hideHeading ? "mx-auto max-w-4xl px-6 lg:px-10" : "mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-12 lg:px-10"}>
|
||||||
|
|
||||||
|
|||||||
@@ -1,89 +1,39 @@
|
|||||||
import React, { useState } from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion'
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import { Reveal } from '../Reveal'
|
import { Reveal } from '../Reveal'
|
||||||
import { PremiumButton } from '../PremiumButton'
|
import { PremiumButton } from '../PremiumButton'
|
||||||
import { ProjectCard } from '../ProjectCard'
|
import { ProjectCard } from '../ProjectCard'
|
||||||
import { SectionHeading } from '../SectionHeading'
|
import { SectionHeading } from '../SectionHeading'
|
||||||
import { useSectionHeading, useSite, useStatusLabel } from '../../api'
|
import { useSectionHeading, useSite } from '../../api'
|
||||||
import { useFieldSaver } from '../../admin/AdminContext'
|
import { useFieldSaver } from '../../admin/AdminContext'
|
||||||
import { useLang } from '../../i18n/lang'
|
import { useLang } from '../../i18n/lang'
|
||||||
import { InlineEdit } from '../admin/InlineEdit'
|
import { InlineEdit } from '../admin/InlineEdit'
|
||||||
import type { ProjectStatus } from '../../data/site'
|
|
||||||
|
|
||||||
export function FeaturedProjects() {
|
export function FeaturedProjects() {
|
||||||
const { status_labels, projects } = useSite()
|
const { projects } = useSite()
|
||||||
const { t, href } = useLang()
|
const { href } = useLang()
|
||||||
const heading = useSectionHeading('featured_projects')
|
const heading = useSectionHeading('featured_projects')
|
||||||
const save = useFieldSaver()
|
const save = useFieldSaver()
|
||||||
|
|
||||||
const saveStatusLabel = (key: string, value: string) => {
|
// سه پروژهٔ ثابت صفحهٔ اصلی — بدون تب وضعیت؛ ویرایش پنل همانجا اعمال میشود
|
||||||
const label = status_labels.find((s) => s.key === key)
|
const FEATURED_TITLES = ['آرامش زنیث', 'آرامش الیسیان', 'آرامش آوالون']
|
||||||
if (!label) return
|
const list = projects.filter((p) => FEATURED_TITLES.includes(p.title))
|
||||||
void save('status-labels', label.id, { label: value }, (d) => {
|
.sort((a, b) => FEATURED_TITLES.indexOf(a.title) - FEATURED_TITLES.indexOf(b.title))
|
||||||
const s = d.status_labels.find((x) => x.id === label.id)
|
|
||||||
if (s) s.label = value
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const FILTERS: { key: ProjectStatus; label: string }[] = [
|
|
||||||
{ key: 'done', label: useStatusLabel('done') },
|
|
||||||
{ key: 'progress', label: useStatusLabel('progress') },
|
|
||||||
]
|
|
||||||
|
|
||||||
const [status, setStatus] = useState<ProjectStatus>('done')
|
|
||||||
|
|
||||||
// سه پروژه از دیتابیس — ویرایش پنل همانجا اعمال میشود
|
|
||||||
const list = projects.filter((p) => p.status === status).slice(0, 3)
|
|
||||||
const BASE_IMG_URL = "/seed-media/2026/09/";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="projects-title">
|
<section className="w-full max-w-full overflow-x-clip bg-char py-16 md:py-24 lg:py-32" aria-labelledby="projects-title">
|
||||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
<div className="mx-auto max-w-site px-4 md:px-6 lg:px-10">
|
||||||
<SectionHeading title={heading.title} lead={heading.lead} headingKey="featured_projects" />
|
<SectionHeading title={heading.title} lead={heading.lead} headingKey="featured_projects" />
|
||||||
|
|
||||||
{/* دکمههای فیلتر مینیمال و شیشهای (بدون کادر) */}
|
<div className="mt-8 min-h-[420px] md:mt-10">
|
||||||
<div className="mt-10 flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label={t('projectFilterAria')}>
|
|
||||||
{FILTERS.map((filter, index) => {
|
|
||||||
const isSelected = status === filter.key
|
|
||||||
return (
|
|
||||||
<React.Fragment key={filter.key}>
|
|
||||||
{index > 0 && (
|
|
||||||
<span className="h-6 w-[2px] rounded-full bg-ivory/10" aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={isSelected}
|
|
||||||
onClick={() => setStatus(filter.key)}
|
|
||||||
className={`group relative rounded-lg px-6 py-2.5 text-sm font-medium transition-all duration-300 ease-lux active:scale-95 cursor-pointer select-none backdrop-blur-md ${
|
|
||||||
isSelected
|
|
||||||
? 'bg-copper/15 text-copper shadow-[0_4px_15px_rgba(197,143,92,0.1)]'
|
|
||||||
: 'bg-transparent text-ivory/60 hover:bg-white/5 hover:text-ivory'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="relative z-10 transition-colors duration-300 ease-out">
|
|
||||||
<InlineEdit
|
|
||||||
value={filter.label}
|
|
||||||
onSave={(v) => saveStatusLabel(filter.key, v)}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</React.Fragment>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-10 min-h-[420px]">
|
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
<motion.div
|
<motion.div
|
||||||
key={status}
|
key="featured"
|
||||||
initial={{ opacity: 0, y: 15 }}
|
initial={{ opacity: 0, y: 15 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -15 }}
|
|
||||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||||
className="grid gap-6 lg:grid-cols-3"
|
className="flex gap-4 overflow-x-auto pb-4 snap-x snap-mandatory [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none] md:grid md:grid-cols-3 md:gap-6 md:overflow-visible md:pb-0"
|
||||||
>
|
>
|
||||||
{list.map((project, i) => {
|
{list.map((project, i) => {
|
||||||
const imageUrl = project.image || undefined;
|
const imageUrl = project.image || undefined;
|
||||||
@@ -94,7 +44,7 @@ export function FeaturedProjects() {
|
|||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5, delay: i * 0.1, ease: [0.23, 1, 0.32, 1] }}
|
transition={{ duration: 0.5, delay: i * 0.1, ease: [0.23, 1, 0.32, 1] }}
|
||||||
className="h-full"
|
className="h-full w-[82vw] max-w-[320px] shrink-0 snap-center md:w-auto md:max-w-none md:shrink"
|
||||||
>
|
>
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
project={project}
|
project={project}
|
||||||
@@ -111,7 +61,7 @@ export function FeaturedProjects() {
|
|||||||
{/* دکمه مشاهده همه پروژهها */}
|
{/* دکمه مشاهده همه پروژهها */}
|
||||||
{heading.cta_label && heading.cta_href && (
|
{heading.cta_label && heading.cta_href && (
|
||||||
<Reveal delay={0.1}>
|
<Reveal delay={0.1}>
|
||||||
<div className="mt-12 flex justify-center">
|
<div className="mt-10 flex justify-center md:mt-12">
|
||||||
<PremiumButton to={href(heading.cta_href)}>
|
<PremiumButton to={href(heading.cta_href)}>
|
||||||
<InlineEdit
|
<InlineEdit
|
||||||
value={heading.cta_label}
|
value={heading.cta_label}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function Gallery() {
|
|||||||
<img
|
<img
|
||||||
src={image.src}
|
src={image.src}
|
||||||
alt={image.alt}
|
alt={image.alt}
|
||||||
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
|
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105" loading="lazy" decoding="async"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</EditableItem>
|
</EditableItem>
|
||||||
|
|||||||
@@ -1,16 +1,55 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { motion, useScroll, useTransform } from 'framer-motion'
|
import { motion, useScroll, useTransform } from 'framer-motion'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react'
|
import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react'
|
||||||
import { ButtonLink } from '../Button'
|
import { ButtonLink } from '../Button'
|
||||||
import { useSite } from '../../api'
|
import { useSite } from '../../api'
|
||||||
import { useLang } from '../../i18n/lang'
|
import { useLang } from '../../i18n/lang'
|
||||||
import { useFieldSaver } from '../../admin/AdminContext'
|
import { useFieldSaver } from '../../admin/AdminContext'
|
||||||
import { InlineEdit } from '../admin/InlineEdit'
|
import { InlineEdit } from '../admin/InlineEdit'
|
||||||
|
import { HeroProjectBadge } from './HeroProjectBadge'
|
||||||
import { SingletonPill } from '../../admin/Editable'
|
import { SingletonPill } from '../../admin/Editable'
|
||||||
|
|
||||||
|
/** واژههای عمومی برچسب نشان که در تطبیق نام پروژه نادیده گرفته میشوند */
|
||||||
|
const NOISE_WORDS = ['پروژه', 'فعال', 'آرامش', 'گروه', 'ساختمانی', 'Active', 'Project', 'Aramesh']
|
||||||
|
/** نام لاتین برچسب انگلیسی → معادل فارسی در عنوان پروژه (پروژههای بدون title_en) */
|
||||||
|
const LATIN_ALIAS: Record<string, string> = { elysian: 'الیسیان' }
|
||||||
|
|
||||||
export function Hero() {
|
export function Hero() {
|
||||||
const { brand, hero } = useSite()
|
const { brand, hero, projects } = useSite()
|
||||||
const { t, href } = useLang()
|
const { t, href, lang } = useLang()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* پروژهٔ فعال هیرو → صفحهٔ همان پروژه.
|
||||||
|
* برچسب نشان مثل «پروژه فعال آرامش الیسیان» یا «Elysian Aramesh Active Project»
|
||||||
|
* است؛ واژههای عمومی حذف و نام باقیمانده با عنوان پروژهها تطبیق داده میشود.
|
||||||
|
*/
|
||||||
|
const activeProjectHref = (() => {
|
||||||
|
const label = hero.badge_label.trim()
|
||||||
|
if (!label) return undefined
|
||||||
|
|
||||||
|
const strip = (text: string) => {
|
||||||
|
let out = text
|
||||||
|
NOISE_WORDS.forEach((w) => {
|
||||||
|
out = out.split(w).join(' ')
|
||||||
|
})
|
||||||
|
return out.replace(/[\s\-—_]+/g, '').trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = strip(label)
|
||||||
|
// برچسب انگلیسی ممکن است نام لاتین داشته باشد و پروژه فقط عنوان فارسی (Elysian → الیسیان)
|
||||||
|
if (key.length < 3 || LATIN_ALIAS[key]) key = LATIN_ALIAS[key] ?? key
|
||||||
|
if (key.length < 3) return undefined
|
||||||
|
|
||||||
|
const hit = projects.find((p) => {
|
||||||
|
const candidates = [p.title, p.title_en ?? ''].filter(Boolean)
|
||||||
|
return candidates.some((title) => {
|
||||||
|
const k = strip(title)
|
||||||
|
return k.length >= 3 && (k.includes(key) || key.includes(k))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return hit ? `/projects/${hit.slug}` : undefined
|
||||||
|
})()
|
||||||
const save = useFieldSaver()
|
const save = useFieldSaver()
|
||||||
const { scrollY } = useScroll()
|
const { scrollY } = useScroll()
|
||||||
const imageY = useTransform(scrollY, [0, 700], [0, 110])
|
const imageY = useTransform(scrollY, [0, 700], [0, 110])
|
||||||
@@ -77,6 +116,22 @@ export function Hero() {
|
|||||||
<InlineEdit multiline value={hero.subtitle} onSave={(v) => saveHero('subtitle', v)} />
|
<InlineEdit multiline value={hero.subtitle} onSave={(v) => saveHero('subtitle', v)} />
|
||||||
</motion.p>
|
</motion.p>
|
||||||
|
|
||||||
|
{/* پیوند درباره ما — زیر تیتر، در هر دو زبان */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 18 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.24, ease: [0.23, 1, 0.32, 1] }}
|
||||||
|
className="mt-4"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to={href('/about')}
|
||||||
|
className="inline-flex items-center gap-1.5 border-b border-ivory/25 pb-0.5 text-sm font-light text-ivory/70 transition-colors duration-200 hover:border-copper hover:text-copper"
|
||||||
|
>
|
||||||
|
{t('aboutUs')}
|
||||||
|
<ArrowLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
{/* داک دکمهها: استایل کپسولی، شیشهای و دایرهای */}
|
{/* داک دکمهها: استایل کپسولی، شیشهای و دایرهای */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 22 }}
|
initial={{ opacity: 0, y: 22 }}
|
||||||
@@ -88,7 +143,7 @@ export function Hero() {
|
|||||||
<ButtonLink
|
<ButtonLink
|
||||||
to={href(hero.cta_primary_href)}
|
to={href(hero.cta_primary_href)}
|
||||||
variant="copper"
|
variant="copper"
|
||||||
className="!p-1.5 !pr-7 !rounded-full shadow-lg shadow-black/40 hover:shadow-copper/25 transition-all duration-300"
|
className="!p-1.5 !ps-7 !rounded-full shadow-lg shadow-black/40 hover:shadow-copper/25 transition-all duration-300"
|
||||||
>
|
>
|
||||||
<span className="text-sm font-medium tracking-wide text-ivory">
|
<span className="text-sm font-medium tracking-wide text-ivory">
|
||||||
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
|
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
|
||||||
@@ -109,19 +164,27 @@ export function Hero() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* کادر آمار شناور (گوشه پایین چپ): شیشهای با لبههای گرد مدرن */}
|
{/* کادر آمار شناور (گوشه پایین چپ) — شیشهٔ چندلایه، خط مویی مسی، هالهٔ نرم و نشان زنده */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, x: -24 }}
|
initial={{ opacity: 0, x: -24 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
transition={{ duration: 0.6, delay: 0.36, ease: [0.23, 1, 0.32, 1] }}
|
transition={{ duration: 0.6, delay: 0.36, ease: [0.23, 1, 0.32, 1] }}
|
||||||
className="absolute bottom-28 ltr:right-6 rtl:left-6 hidden w-64 rounded-2xl border border-ivory/15 bg-char/70 p-6 backdrop-blur-xl shadow-2xl shadow-black/40 lg:block lg:ltr:right-10 lg:rtl:left-10"
|
className="group absolute bottom-28 ltr:right-6 rtl:left-6 hidden w-fit max-w-[22rem] lg:block lg:ltr:right-10 lg:rtl:left-10"
|
||||||
>
|
>
|
||||||
<p className="text-3xl font-bold text-copper font-mono">
|
<div className="relative overflow-hidden rounded-2xl border border-white/10 bg-ink/55 p-6 shadow-2xl shadow-black/50 backdrop-blur-2xl transition-colors duration-500 group-hover:border-copper/40">
|
||||||
<InlineEdit value={hero.badge_value} onSave={(v) => saveHero('badge_value', v)} />
|
{/* خط مویی مسی روی لبهٔ شروع */}
|
||||||
</p>
|
<span aria-hidden="true" className="absolute inset-y-0 start-0 w-[3px] bg-gradient-to-b from-copper/90 via-copper/30 to-transparent" />
|
||||||
<p className="mt-2 text-sm font-light leading-7 text-ivory/70">
|
{/* هالهٔ مسی نرم گوشهٔ مقابل — با هاور پررنگتر */}
|
||||||
<InlineEdit multiline value={hero.badge_label} onSave={(v) => saveHero('badge_label', v)} />
|
<span aria-hidden="true" className="absolute -top-20 -end-20 h-44 w-44 rounded-full bg-copper/10 blur-3xl transition-colors duration-700 group-hover:bg-copper/20" />
|
||||||
</p>
|
|
||||||
|
<div className="relative">
|
||||||
|
<HeroProjectBadge
|
||||||
|
value={String(hero.badge_value ?? '').trim()}
|
||||||
|
label={hero.badge_label}
|
||||||
|
href={activeProjectHref ? href(activeProjectHref) : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Building2Icon } from 'lucide-react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* نشان «پروژه فعال» هیرو — آیکون و متن کنار هم، با فاصلهٔ یکنواخت و
|
||||||
|
* چیدمان وسطچین. فلکسباکس ستون را از بین میبرد تا فاصلهٔ آیکون و متن
|
||||||
|
* یکدست بماند و آیکون درست با متن همتراز شود.
|
||||||
|
*/
|
||||||
|
export function HeroProjectBadge({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
href,
|
||||||
|
}: {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
/** نشانی صفحهٔ پروژهٔ فعال — نشان کلیکپذیر میشود و به همان پروژه میرود */
|
||||||
|
href?: string
|
||||||
|
}) {
|
||||||
|
const body = (
|
||||||
|
<div className="flex items-center gap-3.5 text-center">
|
||||||
|
{/* آیکون در کادر مربعی با نشان زندهٔ مسی */}
|
||||||
|
<span className="relative flex h-14 w-14 shrink-0 items-center justify-center rounded-xl border border-copper/40 bg-copper/10">
|
||||||
|
<Building2Icon className="h-6 w-6 text-copper" aria-hidden="true" />
|
||||||
|
<span className="absolute -end-1 -top-1 flex h-3 w-3" aria-hidden="true">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-copper opacity-60" />
|
||||||
|
<span className="relative inline-flex h-3 w-3 rounded-full border border-ink bg-copper" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
{value && <p className="font-mono text-[2.5rem] font-bold leading-none tracking-tight text-copper">{value}</p>}
|
||||||
|
<p className={`text-center text-[15px] font-light leading-7 text-ivory/85 ${value ? 'mt-2' : ''}`}>{label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// بدون نشانی همان ظرف ساده میماند؛ با نشانی، کل نشان یک پیوند به پروژه است
|
||||||
|
if (!href) return body
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={href}
|
||||||
|
className="block rounded-2xl transition-transform duration-300 ease-lux hover:-translate-y-0.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from 'react'
|
|
||||||
import { Reveal } from '../Reveal'
|
import { Reveal } from '../Reveal'
|
||||||
import { ServiceIcon } from '../ServiceIcon'
|
import { ServiceIcon } from '../ServiceIcon'
|
||||||
import { useSectionHeading, useSite } from '../../api'
|
import { useSectionHeading, useSite } from '../../api'
|
||||||
@@ -6,7 +5,7 @@ import { useFieldSaver } from '../../admin/AdminContext'
|
|||||||
import { InlineEdit } from '../admin/InlineEdit'
|
import { InlineEdit } from '../admin/InlineEdit'
|
||||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||||
|
|
||||||
export function Services() {
|
export function Services({ tone = 'black' }: { tone?: 'black' | 'gray' } = {}) {
|
||||||
const { services } = useSite()
|
const { services } = useSite()
|
||||||
const heading = useSectionHeading('services')
|
const heading = useSectionHeading('services')
|
||||||
const save = useFieldSaver()
|
const save = useFieldSaver()
|
||||||
@@ -18,32 +17,35 @@ export function Services() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="services-title">
|
<section className={`w-full max-w-full overflow-hidden ${tone === 'black' ? 'bg-char' : 'bg-ink'} py-12 md:py-24 lg:py-32`} aria-labelledby="services-title">
|
||||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
<div className="mx-auto max-w-site px-4 md:px-6 lg:px-10">
|
||||||
<Reveal className="max-w-2xl text-start">
|
<Reveal className="max-w-2xl text-start">
|
||||||
|
|
||||||
<h2 id="services-title" className="text-section font-bold text-ivory">
|
<h2 id="services-title" className="text-section font-bold text-ivory">
|
||||||
<InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} />
|
<InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} />
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-5 text-base font-light leading-9 text-ivory/60">
|
<p className="mt-3 text-sm font-light leading-7 text-ivory/60 md:mt-5 md:text-base md:leading-9">
|
||||||
<InlineEdit multiline value={heading.lead} onSave={(v) => saveHeading('lead', v)} />
|
<InlineEdit multiline value={heading.lead} onSave={(v) => saveHeading('lead', v)} />
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6 h-px w-24 bg-copper/70" />
|
<div className="mt-4 h-px w-24 bg-copper/70 md:mt-6" />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<div className="mt-14 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="mt-8 grid grid-cols-2 gap-3 md:mt-14 md:gap-5 md:grid-cols-2 lg:grid-cols-4">
|
||||||
{services.map((service, i) => (
|
{services.map((service, i) => (
|
||||||
<Reveal key={service.id} delay={0.04 * i} className="h-full">
|
<Reveal key={service.id} delay={0.04 * i} className="h-full">
|
||||||
<EditableItem resource="services" item={service} className="h-full">
|
<EditableItem resource="services" item={service} className="h-full">
|
||||||
<article className="group flex h-full flex-col items-center border border-ivory/10 bg-char p-8 text-center transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform">
|
<article className="group flex h-full flex-col items-center rounded-xl border border-ivory/10 bg-char p-3.5 text-center transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform md:rounded-2xl md:p-8">
|
||||||
<ServiceIcon
|
<div className="flex h-10 w-10 items-center justify-center md:h-14 md:w-14">
|
||||||
name={service.icon}
|
<ServiceIcon
|
||||||
className="h-12 w-12 text-copper/85 transition-colors duration-300 ease-out group-hover:text-copper"
|
name={service.icon}
|
||||||
/>
|
className="h-10 w-10 text-copper/85 transition-colors duration-300 ease-out group-hover:text-copper md:h-14 md:w-14"
|
||||||
<h3 className="mt-6 text-lg font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
|
/>
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-3 text-sm font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper md:mt-6 md:text-lg">
|
||||||
{service.title}
|
{service.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-3 text-sm font-light leading-8 text-ivory/55">{service.desc}</p>
|
<p className="mt-2 text-xs font-light leading-relaxed text-ivory/55 md:mt-3 md:text-sm md:leading-8">
|
||||||
|
{service.desc}
|
||||||
|
</p>
|
||||||
</article>
|
</article>
|
||||||
</EditableItem>
|
</EditableItem>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useSite } from '../../api';
|
import { useSite } from '../../api';
|
||||||
import { useLang, formatNumber } from '../../i18n/lang';
|
import { useLang, formatNumber } from '../../i18n/lang';
|
||||||
import { useFieldSaver } from '../../admin/AdminContext';
|
import { useFieldSaver } from '../../admin/AdminContext';
|
||||||
@@ -65,18 +65,18 @@ function Counter({ value }: { value: string | number }) {
|
|||||||
return <span ref={ref}>{renderValue}</span>;
|
return <span ref={ref}>{renderValue}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Stats() {
|
export function Stats({ tone = 'black' }: { tone?: 'black' | 'gray' } = {}) {
|
||||||
const { stats } = useSite();
|
const { stats } = useSite();
|
||||||
const { t } = useLang();
|
const { t } = useLang();
|
||||||
const save = useFieldSaver();
|
const save = useFieldSaver();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-char py-16" aria-label={t('statsAria')}>
|
<section className={`w-full max-w-full overflow-x-clip ${tone === 'black' ? 'bg-char' : 'bg-ink'} py-8 md:py-16`} aria-label={t('statsAria')}>
|
||||||
<div className="mx-auto grid max-w-site grid-cols-2 gap-y-12 px-6 lg:grid-cols-4 lg:px-10">
|
<div className="mx-auto grid max-w-site grid-cols-2 gap-3.5 p-4 md:grid-cols-4 md:gap-y-12 md:px-10 md:py-0">
|
||||||
{stats.map((stat) =>
|
{stats.map((stat) =>
|
||||||
<EditableItem key={stat.id} resource="stats" item={stat}>
|
<EditableItem key={stat.id} resource="stats" item={stat}>
|
||||||
<div className="text-center">
|
<div className="p-3.5 text-center md:p-0">
|
||||||
<p className="text-4xl font-bold text-copper md:text-5xl">
|
<p className="text-2xl font-bold text-copper md:text-5xl">
|
||||||
<Counter value={stat.value} />
|
<Counter value={stat.value} />
|
||||||
<InlineEdit
|
<InlineEdit
|
||||||
value={stat.suffix}
|
value={stat.suffix}
|
||||||
@@ -84,10 +84,10 @@ export function Stats() {
|
|||||||
const s = d.stats.find((x) => x.id === stat.id);
|
const s = d.stats.find((x) => x.id === stat.id);
|
||||||
if (s) s.suffix = v;
|
if (s) s.suffix = v;
|
||||||
})}
|
})}
|
||||||
className="ltr:ml-1 rtl:mr-1 text-2xl"
|
className="ltr:ml-1 rtl:mr-1 text-lg md:text-2xl"
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-3 text-sm font-light text-ivory/55">
|
<p className="mt-2 text-xs font-light text-ivory/55 md:mt-3 md:text-sm">
|
||||||
<InlineEdit
|
<InlineEdit
|
||||||
value={stat.label}
|
value={stat.label}
|
||||||
onSave={(v) => save('stats', stat.id, { label: v }, (d) => {
|
onSave={(v) => save('stats', stat.id, { label: v }, (d) => {
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ type StoryProps = {
|
|||||||
showQuote?: boolean;
|
showQuote?: boolean;
|
||||||
alignTop?: boolean;
|
alignTop?: boolean;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
/** پسزمینه: black = char (تیره) و gray = ink (روشنتر) — ریتم تناوب بخشها */
|
||||||
|
tone?: 'black' | 'gray';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Story({ showLink = true, showLabel = true, showQuote = false, alignTop = false, children }: StoryProps) {
|
export function Story({ showLink = true, showLabel = true, showQuote = false, alignTop = false, tone = 'gray', children }: StoryProps) {
|
||||||
const { story } = useSite()
|
const { story } = useSite()
|
||||||
const { href } = useLang()
|
const { href } = useLang()
|
||||||
const save = useFieldSaver()
|
const save = useFieldSaver()
|
||||||
@@ -33,7 +35,7 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="story" className="bg-ink py-24 lg:py-32" aria-labelledby="story-title">
|
<section id="story" className={`${tone === 'black' ? 'bg-char' : 'bg-ink'} py-24 lg:py-32`} aria-labelledby="story-title">
|
||||||
<div className={`mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-2 lg:px-10 ${alignTop ? 'items-start' : 'items-center'}`}>
|
<div className={`mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-2 lg:px-10 ${alignTop ? 'items-start' : 'items-center'}`}>
|
||||||
<div>
|
<div>
|
||||||
{showLabel && (
|
{showLabel && (
|
||||||
@@ -47,7 +49,7 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
|
|||||||
|
|
||||||
<Reveal delay={0.08}>
|
<Reveal delay={0.08}>
|
||||||
{story.paragraphs.map((paragraph, i) => (
|
{story.paragraphs.map((paragraph, i) => (
|
||||||
<p key={i} className={`text-base font-light leading-9 text-ivory/65 ${i === 0 ? 'mt-8' : 'mt-5'}`}>
|
<p key={i} className={`text-base font-light leading-9 text-ivory/65 text-justify ${i === 0 ? 'mt-8' : 'mt-5'}`}>
|
||||||
<InlineEdit multiline value={paragraph} onSave={(v) => saveParagraph(i, v)} />
|
<InlineEdit multiline value={paragraph} onSave={(v) => saveParagraph(i, v)} />
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
@@ -111,7 +113,7 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
|
|||||||
<img
|
<img
|
||||||
src={item.src}
|
src={item.src}
|
||||||
alt={item.alt}
|
alt={item.alt}
|
||||||
className="aspect-square w-full object-cover transition-transform duration-500 ease-lux hover:scale-105"
|
className="aspect-square w-full object-cover transition-transform duration-500 ease-lux hover:scale-105" loading="lazy" decoding="async"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import React from 'react'
|
|
||||||
import { Reveal } from '../Reveal'
|
import { Reveal } from '../Reveal'
|
||||||
import { SectionHeading } from '../SectionHeading'
|
import { SectionHeading } from '../SectionHeading'
|
||||||
import { useSite } from '../../api'
|
import { useSite } from '../../api'
|
||||||
import { useLang } from '../../i18n/lang'
|
import { useLang } from '../../i18n/lang'
|
||||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||||
|
|
||||||
export function Team() {
|
export function Team({ tone = 'gray' }: { tone?: 'black' | 'gray' } = {}) {
|
||||||
const { team } = useSite()
|
const { team } = useSite()
|
||||||
const { t } = useLang()
|
const { t } = useLang()
|
||||||
return (
|
return (
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="team-title" >
|
<section className={`w-full max-w-full overflow-x-clip ${tone === 'black' ? 'bg-char' : 'bg-ink'} py-16 md:py-24 lg:py-32`} aria-labelledby="team-title" >
|
||||||
<div className="mx-auto max-w-site px-6 lg:px-10 ">
|
<div className="mx-auto max-w-site px-4 md:px-6 lg:px-10">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
title={t('fallbackTeamTitle')}
|
title={t('fallbackTeamTitle')}
|
||||||
lead={t('fallbackTeamLead')}
|
lead={t('fallbackTeamLead')}
|
||||||
@@ -18,16 +17,21 @@ export function Team() {
|
|||||||
align="center"
|
align="center"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-16 grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="mx-auto mt-8 flex max-w-5xl flex-wrap justify-center gap-4 md:mt-16 md:gap-8">
|
||||||
{team.map((member, i) => (
|
{team.map((member, i) => (
|
||||||
<Reveal key={member.id} delay={0.06 * i} className="h-full">
|
<Reveal
|
||||||
|
key={member.id}
|
||||||
|
delay={0.06 * i}
|
||||||
|
className={`h-full ${
|
||||||
|
i === 0
|
||||||
|
? 'w-full max-w-[19rem] md:max-w-[19rem]' /* مدیرعامل — تنها در ردیف اول، وسط */
|
||||||
|
: 'w-[calc(50%-0.5rem)] max-w-[19rem] md:w-[calc(50%-1rem)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<EditableItem resource="team" item={member} className="h-full">
|
<EditableItem resource="team" item={member} className="h-full">
|
||||||
{/* اضافه شدن rounded-2xl برای گرد شدن گوشهها */}
|
{/* اضافه شدن rounded-2xl برای گرد شدن گوشهها */}
|
||||||
<article className="group flex h-full flex-col items-center rounded-2xl border border-ivory/10 bg-char p-10 text-center transition-[transform,box-shadow,border-color] duration-500 ease-out hover:-translate-y-3 hover:border-copper/50 hover:shadow-lift">
|
<article className="group flex h-full flex-col items-center rounded-xl border border-ivory/10 bg-char p-4 text-center md:rounded-2xl md:p-10 transition-[transform,box-shadow,border-color] duration-500 ease-out hover:-translate-y-3 hover:border-copper/50 hover:shadow-lift">
|
||||||
<div className="flex h-24 w-24 items-center justify-center rounded-full border border-copper/30 text-3xl font-bold text-copper transition-colors duration-500 group-hover:border-copper">
|
<h3 className="text-lg font-bold text-ivory">{member.name}</h3>
|
||||||
{member.name.trim().charAt(0)}
|
|
||||||
</div>
|
|
||||||
<h3 className="mt-8 text-lg font-bold text-ivory">{member.name}</h3>
|
|
||||||
<p className="mt-2 text-sm font-light text-copper">{member.role}</p>
|
<p className="mt-2 text-sm font-light text-copper">{member.role}</p>
|
||||||
<p className="mt-6 border-t border-ivory/10 pt-6 text-sm font-light text-ivory/60">
|
<p className="mt-6 border-t border-ivory/10 pt-6 text-sm font-light text-ivory/60">
|
||||||
{member.bio}
|
{member.bio}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion, PanInfo } from 'framer-motion';
|
||||||
import { ChevronLeftIcon, ChevronRightIcon, QuoteIcon } from 'lucide-react';
|
import { ChevronLeftIcon, ChevronRightIcon, QuoteIcon } from 'lucide-react';
|
||||||
import { SectionHeading } from '../SectionHeading';
|
import { SectionHeading } from '../SectionHeading';
|
||||||
import { useSite } from '../../api';
|
import { useSite } from '../../api';
|
||||||
@@ -17,39 +17,57 @@ export function Testimonials() {
|
|||||||
setIndex((prev) => (prev + dir + testimonials.length) % testimonials.length);
|
setIndex((prev) => (prev + dir + testimonials.length) % testimonials.length);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = (_: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
|
||||||
|
if (info.offset.x > 50) {
|
||||||
|
go(1); // سوایپ به راست
|
||||||
|
} else if (info.offset.x < -50) {
|
||||||
|
go(-1); // سوایپ به چپ
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="border-y border-ivory/10 bg-char py-24 lg:py-32" aria-labelledby="testimonials-title">
|
<section className="border-y border-ivory/10 bg-char py-14 lg:py-28" aria-labelledby="testimonials-title">
|
||||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||||
<SectionHeading headingKey="testimonials" align="center" />
|
<SectionHeading headingKey="testimonials" align="center" />
|
||||||
|
|
||||||
<div className="relative mx-auto mt-14 max-w-3xl text-center">
|
<div className="relative mx-auto mt-8 max-w-3xl text-center select-none">
|
||||||
<QuoteIcon className="mx-auto h-8 w-8 text-copper/60" aria-hidden="true" />
|
<QuoteIcon className="mx-auto h-6 w-6 text-copper/60 md:h-8 md:w-8" aria-hidden="true" />
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
<motion.blockquote
|
|
||||||
key={current.id}
|
|
||||||
initial={{ opacity: 0, y: 16 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -12 }}
|
|
||||||
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}>
|
|
||||||
|
|
||||||
<EditableItem resource="testimonials" item={current}>
|
<div className="relative min-h-[170px] overflow-hidden md:min-h-[190px]">
|
||||||
<p className="mt-8 text-lg font-light leading-[2.4rem] text-ivory/85 md:text-xl">«{current.quote}»</p>
|
<AnimatePresence mode="wait">
|
||||||
<footer className="mt-8">
|
<motion.blockquote
|
||||||
<p className="text-base font-bold text-copper">{current.name}</p>
|
key={current.id}
|
||||||
<p className="mt-1 text-sm font-light text-ivory/45">{current.role}</p>
|
drag="x"
|
||||||
</footer>
|
dragConstraints={{ left: 0, right: 0 }}
|
||||||
</EditableItem>
|
dragElastic={0.2}
|
||||||
</motion.blockquote>
|
onDragEnd={handleDragEnd}
|
||||||
</AnimatePresence>
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -12 }}
|
||||||
|
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
|
||||||
|
className="cursor-grab active:cursor-grabbing"
|
||||||
|
>
|
||||||
|
<EditableItem resource="testimonials" item={current}>
|
||||||
|
<p className="mt-5 text-base font-light leading-8 text-ivory/85 md:mt-8 md:text-xl md:leading-[2.4rem]">
|
||||||
|
«{current.quote}»
|
||||||
|
</p>
|
||||||
|
<footer className="mt-5 md:mt-8">
|
||||||
|
<p className="text-sm font-bold text-copper md:text-base">{current.name}</p>
|
||||||
|
<p className="mt-0.5 text-xs font-light text-ivory/45 md:text-sm">{current.role}</p>
|
||||||
|
</footer>
|
||||||
|
</EditableItem>
|
||||||
|
</motion.blockquote>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 flex items-center justify-center gap-5">
|
<div className="mt-6 flex items-center justify-center gap-5 md:mt-10">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => go(1)}
|
onClick={() => go(1)}
|
||||||
aria-label={t('prevTestimonial')}
|
aria-label={t('prevTestimonial')}
|
||||||
className="group flex h-12 w-12 items-center justify-center rounded-full border border-copper/35 bg-char/80 text-ivory/80 backdrop-blur-md shadow-[0_4px_20px_rgba(0,0,0,0.35)] transition-all duration-300 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_25px_rgba(197,143,92,0.45)] active:scale-95"
|
className="group flex h-10 w-10 items-center justify-center rounded-full border border-copper/35 bg-char/80 text-ivory/80 shadow-[0_4px_20px_rgba(0,0,0,0.35)] backdrop-blur-md transition-all duration-300 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_25px_rgba(197,143,92,0.45)] active:scale-95 md:h-12 md:w-12"
|
||||||
>
|
>
|
||||||
<ChevronRightIcon className="h-5 w-5 ltr:rotate-180 transition-transform duration-300 ease-lux rtl:group-hover:translate-x-0.5 ltr:group-hover:-translate-x-0.5" />
|
<ChevronRightIcon className="h-4 w-4 transition-transform duration-300 ease-lux ltr:rotate-180 rtl:group-hover:translate-x-0.5 ltr:group-hover:-translate-x-0.5 md:h-5 md:w-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center gap-2" role="tablist" aria-label={t('selectTestimonialAria')}>
|
<div className="flex items-center gap-2" role="tablist" aria-label={t('selectTestimonialAria')}>
|
||||||
@@ -63,8 +81,8 @@ export function Testimonials() {
|
|||||||
onClick={() => setIndex(i)}
|
onClick={() => setIndex(i)}
|
||||||
className={`h-2 rounded-full transition-all duration-300 ease-lux ${
|
className={`h-2 rounded-full transition-all duration-300 ease-lux ${
|
||||||
i === index
|
i === index
|
||||||
? 'w-8 bg-copper shadow-[0_0_12px_rgba(197,143,92,0.6)]'
|
? 'w-7 bg-copper shadow-[0_0_12px_rgba(197,143,92,0.6)] md:w-8'
|
||||||
: 'w-2.5 bg-ivory/20 hover:bg-ivory/40'
|
: 'w-2 bg-ivory/20 hover:bg-ivory/40 md:w-2.5'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -74,9 +92,9 @@ export function Testimonials() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => go(-1)}
|
onClick={() => go(-1)}
|
||||||
aria-label={t('nextTestimonial')}
|
aria-label={t('nextTestimonial')}
|
||||||
className="group flex h-12 w-12 items-center justify-center rounded-full border border-copper/35 bg-char/80 text-ivory/80 backdrop-blur-md shadow-[0_4px_20px_rgba(0,0,0,0.35)] transition-all duration-300 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_25px_rgba(197,143,92,0.45)] active:scale-95"
|
className="group flex h-10 w-10 items-center justify-center rounded-full border border-copper/35 bg-char/80 text-ivory/80 shadow-[0_4px_20px_rgba(0,0,0,0.35)] backdrop-blur-md transition-all duration-300 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_25px_rgba(197,143,92,0.45)] active:scale-95 md:h-12 md:w-12"
|
||||||
>
|
>
|
||||||
<ChevronLeftIcon className="h-5 w-5 ltr:rotate-180 transition-transform duration-300 ease-lux rtl:group-hover:-translate-x-0.5 ltr:group-hover:translate-x-0.5" />
|
<ChevronLeftIcon className="h-4 w-4 transition-transform duration-300 ease-lux ltr:rotate-180 rtl:group-hover:-translate-x-0.5 ltr:group-hover:translate-x-0.5 md:h-5 md:w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useRef } from 'react'
|
import React, { useLayoutEffect, useRef, useState } from 'react'
|
||||||
import { motion, useScroll, useTransform, useInView } from 'framer-motion'
|
import { motion, useScroll, useTransform, useInView } from 'framer-motion'
|
||||||
import { useSite, type TimelineItem } from '../../api'
|
import { useSite, type TimelineItem } from '../../api'
|
||||||
import { useLang } from '../../i18n/lang'
|
import { useLang } from '../../i18n/lang'
|
||||||
@@ -9,17 +9,17 @@ const ease = [0.23, 1, 0.32, 1] as const
|
|||||||
|
|
||||||
type Milestone = TimelineItem
|
type Milestone = TimelineItem
|
||||||
|
|
||||||
function Item({ item, index }: { item: Milestone; index: number }) {
|
function Item({ item, index, featured = false, onLi }: { item: Milestone; index: number; featured?: boolean; onLi?: (el: HTMLLIElement | null) => void }) {
|
||||||
const ref = useRef<HTMLLIElement>(null)
|
const ref = useRef<HTMLLIElement | null>(null)
|
||||||
const active = useInView(ref, { once: true, amount: 0.6 })
|
const active = useInView(ref, { once: true, amount: 0.6 })
|
||||||
const onRight = index % 2 === 0
|
const onRight = index % 2 === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li ref={ref} className="relative md:grid md:grid-cols-2 md:gap-16">
|
<li ref={(el) => { ref.current = el; onLi?.(el) }} className={`relative md:grid md:grid-cols-2 md:gap-16 ${featured ? 'mt-10 md:mt-16' : ''}`}>
|
||||||
{/* دایرهها: مرکز دایره در موبایل و دسکتاپ دقیقاً روی مرکز خط 2px است */}
|
{/* دایرهها: مرکز دایره در موبایل و دسکتاپ دقیقاً روی مرکز خط 2px است */}
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={`absolute right-[-5px] top-8 z-10 h-3 w-3 rounded-full transition-all duration-500 ease-lux md:right-1/2 md:translate-x-1/2 ${
|
className={`absolute right-[-5px] z-10 -translate-y-1/2 h-3 w-3 rounded-full transition-all duration-500 ease-lux md:right-1/2 md:translate-x-1/2 ${featured ? 'top-0' : 'top-1/2'} ${
|
||||||
active
|
active
|
||||||
? 'bg-copper shadow-[0_0_15px_rgba(197,143,92,0.6),0_0_0_6px_rgba(213,135,76,0.15)]'
|
? 'bg-copper shadow-[0_0_15px_rgba(197,143,92,0.6),0_0_0_6px_rgba(213,135,76,0.15)]'
|
||||||
: 'bg-ivory/20'
|
: 'bg-ivory/20'
|
||||||
@@ -28,14 +28,18 @@ function Item({ item, index }: { item: Milestone; index: number }) {
|
|||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className={`pr-8 md:pr-0 ${
|
className={`pr-8 md:pr-0 ${
|
||||||
onRight ? 'md:col-start-1 md:pl-16 md:text-left' : 'md:col-start-2 md:pr-16'
|
featured
|
||||||
|
? 'md:col-span-2 md:col-start-1 md:max-w-xl md:mx-auto'
|
||||||
|
: onRight
|
||||||
|
? 'md:col-start-1 md:pl-16 md:text-left'
|
||||||
|
: 'md:col-start-2 md:pr-16'
|
||||||
}`}
|
}`}
|
||||||
initial={{ opacity: 0, y: 24 }}
|
initial={{ opacity: 0, y: 24 }}
|
||||||
animate={active ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
|
animate={active ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
|
||||||
transition={{ duration: 0.55, ease }}
|
transition={{ duration: 0.55, ease }}
|
||||||
>
|
>
|
||||||
<EditableItem resource="timeline" item={item}>
|
<EditableItem resource="timeline" item={item}>
|
||||||
<div className="group relative overflow-hidden rounded-2xl border border-ivory/10 bg-ink/40 p-6 backdrop-blur-md transition-all duration-500 ease-lux hover:-translate-y-1.5 hover:border-copper/50 hover:bg-ink/60 hover:shadow-[0_12px_40px_-10px_rgba(197,143,92,0.35)] md:p-8">
|
<div className="group relative overflow-hidden rounded-2xl border border-ivory/10 bg-ink/40 p-8 backdrop-blur-md transition-all duration-500 ease-lux hover:-translate-y-1.5 hover:border-copper/50 hover:bg-ink/60 hover:shadow-[0_12px_40px_-10px_rgba(197,143,92,0.35)]">
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.03] to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
|
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.03] to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
|
||||||
|
|
||||||
<span className="relative z-10 text-sm font-medium tracking-widest text-copper">{item.year}</span>
|
<span className="relative z-10 text-sm font-medium tracking-widest text-copper">{item.year}</span>
|
||||||
@@ -48,21 +52,43 @@ function Item({ item, index }: { item: Milestone; index: number }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Timeline() {
|
export function Timeline({ tone = 'gray' }: { tone?: 'black' | 'gray' } = {}) {
|
||||||
const { timeline } = useSite()
|
const { timeline } = useSite()
|
||||||
const { t } = useLang()
|
const { t } = useLang()
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLOListElement>(null)
|
||||||
|
const lastLiRef = useRef<HTMLLIElement | null>(null)
|
||||||
|
// فاصلهٔ انتهای فهرست تا بالای کارت پایانی — خط دقیقاً روی بالای کارت تمام میشود
|
||||||
|
const [lineBottom, setLineBottom] = useState(0)
|
||||||
const { scrollYProgress } = useScroll({
|
const { scrollYProgress } = useScroll({
|
||||||
target: ref,
|
target: ref,
|
||||||
offset: ['start 70%', 'end 60%'],
|
offset: ['start 70%', 'end 110%'],
|
||||||
})
|
})
|
||||||
const scaleY = useTransform(scrollYProgress, [0, 1], [0, 1])
|
const scaleY = useTransform(scrollYProgress, [0, 1], [0, 1])
|
||||||
|
|
||||||
return (
|
useLayoutEffect(() => {
|
||||||
<section className="bg-char py-24 md:py-32" aria-labelledby="timeline-title">
|
const measure = () => {
|
||||||
<div className="mx-auto w-full max-w-site px-6 lg:px-10">
|
const ol = ref.current
|
||||||
|
const li = lastLiRef.current
|
||||||
|
if (!ol || !li) return
|
||||||
|
const gap = ol.getBoundingClientRect().bottom - li.getBoundingClientRect().top
|
||||||
|
setLineBottom(Math.max(0, Math.round(gap)))
|
||||||
|
}
|
||||||
|
measure()
|
||||||
|
const observer = new ResizeObserver(measure)
|
||||||
|
if (ref.current) observer.observe(ref.current)
|
||||||
|
if (lastLiRef.current) observer.observe(lastLiRef.current)
|
||||||
|
window.addEventListener('resize', measure)
|
||||||
|
return () => {
|
||||||
|
observer.disconnect()
|
||||||
|
window.removeEventListener('resize', measure)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
<div className="mb-16">
|
return (
|
||||||
|
<section className={`w-full max-w-full overflow-x-clip ${tone === 'black' ? 'bg-char' : 'bg-ink'} py-16 md:py-24 lg:py-32`} aria-labelledby="timeline-title">
|
||||||
|
<div className="mx-auto w-full max-w-site px-4 md:px-6 lg:px-10">
|
||||||
|
|
||||||
|
<div className="mb-8 md:mb-16">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
title={t('fallbackTimelineTitle')}
|
title={t('fallbackTimelineTitle')}
|
||||||
lead={t('fallbackTimelineLead')}
|
lead={t('fallbackTimelineLead')}
|
||||||
@@ -71,28 +97,30 @@ export function Timeline() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={ref} className="relative mx-auto mt-20 max-w-4xl">
|
<div className="relative mx-auto mt-20 max-w-4xl">
|
||||||
{/* خط پسزمینه ثابت - عرض 2px و همراستایی دقیق با مرکز */}
|
{/* خط روی خود فهرست بسته میشود؛ انتهایش به انتهای کارت آخر (مرکز دایره) میرسد */}
|
||||||
<span
|
<ol ref={ref} className="relative flex flex-col gap-12 md:gap-16">
|
||||||
aria-hidden="true"
|
{/* خط پسزمینه ثابت - عرض 2px و همراستایی دقیق با مرکز */}
|
||||||
className="absolute inset-y-0 right-0 w-[2px] bg-ivory/10 md:right-1/2 md:translate-x-1/2"
|
<span
|
||||||
/>
|
aria-hidden="true"
|
||||||
{/* خط متحرک - عرض 2px و همراستایی دقیق با مرکز */}
|
className="absolute inset-y-0 right-0 w-[2px] bg-ivory/10 md:right-1/2 md:translate-x-1/2"
|
||||||
<motion.span
|
style={{ bottom: lineBottom }}
|
||||||
aria-hidden="true"
|
/>
|
||||||
className="absolute inset-y-0 right-0 w-[2px] origin-top bg-gradient-to-b from-copper/80 via-copper to-copper/20 shadow-[0_0_10px_rgba(197,143,92,0.5)] md:right-1/2 md:translate-x-1/2"
|
{/* خط متحرک - عرض 2px و همراستایی دقیق با مرکز */}
|
||||||
style={{ scaleY }}
|
<motion.span
|
||||||
/>
|
aria-hidden="true"
|
||||||
|
className="absolute inset-y-0 right-0 w-[2px] origin-top bg-gradient-to-b from-copper/80 via-copper to-copper/55 shadow-[0_0_10px_rgba(197,143,92,0.5)] md:right-1/2 md:translate-x-1/2"
|
||||||
<ol className="flex flex-col gap-12 md:gap-16">
|
style={{ scaleY, bottom: lineBottom }}
|
||||||
|
/>
|
||||||
{timeline.map((item, i) => (
|
{timeline.map((item, i) => (
|
||||||
<Item key={item.id} item={item} index={i} />
|
<Item key={item.id} item={item} index={i} featured={i === timeline.length - 1} onLi={i === timeline.length - 1 ? (el) => { lastLiRef.current = el } : undefined} />
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
<div className="mt-12">
|
<div className="mt-12">
|
||||||
<AddItemButton resource="timeline" />
|
<AddItemButton resource="timeline" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Reveal } from '../Reveal';
|
import { Reveal } from '../Reveal';
|
||||||
import { SectionHeading } from '../SectionHeading';
|
import { SectionHeading } from '../SectionHeading';
|
||||||
import { useSectionHeading, useSite } from '../../api';
|
import { useSectionHeading, useSite } from '../../api';
|
||||||
import { AddItemButton, EditableItem, SingletonPill } from '../../admin/Editable';
|
import { AddItemButton, EditableItem } from '../../admin/Editable';
|
||||||
import { InlineEdit } from '../admin/InlineEdit';
|
import { InlineEdit } from '../admin/InlineEdit';
|
||||||
import { useFieldSaver } from '../../admin/AdminContext';
|
import { useFieldSaver } from '../../admin/AdminContext';
|
||||||
import { useLang, formatNumber } from '../../i18n/lang';
|
import { useLang, formatNumber } from '../../i18n/lang';
|
||||||
|
|
||||||
export function WhyUs() {
|
export function WhyUs({ tone = 'black' }: { tone?: 'black' | 'gray' } = {}) {
|
||||||
const { why_us } = useSite();
|
const { why_us } = useSite();
|
||||||
const { lang } = useLang();
|
const { lang } = useLang();
|
||||||
const heading = useSectionHeading('why_us');
|
const heading = useSectionHeading('why_us');
|
||||||
@@ -19,9 +18,15 @@ export function WhyUs() {
|
|||||||
if (it) Object.assign(it, changes);
|
if (it) Object.assign(it, changes);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const formatIndex = (index: number) => {
|
||||||
|
return lang === 'fa'
|
||||||
|
? `۰${formatNumber(index + 1, lang)}`
|
||||||
|
: String(index + 1).padStart(2, '0');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="why-title">
|
<section className={`w-full max-w-full overflow-x-clip ${tone === 'black' ? 'bg-char' : 'bg-ink'} py-16 md:py-24 lg:py-32`} aria-labelledby="why-title">
|
||||||
<div className="mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-12 lg:px-10">
|
<div className="mx-auto grid max-w-site gap-8 px-4 md:gap-14 md:px-6 lg:grid-cols-12 lg:px-10">
|
||||||
<div className="lg:col-span-5">
|
<div className="lg:col-span-5">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
title={heading.title}
|
title={heading.title}
|
||||||
@@ -33,42 +38,87 @@ export function WhyUs() {
|
|||||||
<img
|
<img
|
||||||
src={heading.image}
|
src={heading.image}
|
||||||
alt={heading.title}
|
alt={heading.title}
|
||||||
className="mt-10 h-80 w-full object-cover" />
|
className="mt-10 h-80 w-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async" />
|
||||||
|
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-7">
|
<div className="lg:col-span-7">
|
||||||
<div className="grid gap-x-10 gap-y-px sm:grid-cols-2">
|
{/* Mobile 2-Column Luxury Micro-Card Grid */}
|
||||||
{why_us.map((item, i) =>
|
<div className="block md:hidden px-3.5 py-2">
|
||||||
<Reveal key={item.id} delay={0.05 * i}>
|
<div className="grid grid-cols-2 gap-2.5 items-stretch">
|
||||||
<EditableItem resource="why_us" item={item}>
|
{why_us.map((item, i) => (
|
||||||
<div className="flex h-full flex-col border-t border-ivory/10 py-8">
|
<EditableItem key={item.id} resource="why_us" item={item} className="h-full">
|
||||||
<span className="text-xs font-light text-copper">{lang === 'fa' ? `۰${formatNumber(i + 1, lang)}` : formatNumber(i + 1, lang).padStart(2, '0')}</span>
|
<div className="h-full p-3 rounded-xl border border-ivory/10 bg-char/40 flex flex-col justify-between hover:border-copper/40 transition-all duration-200">
|
||||||
<h3 className="mt-4 text-lg font-bold text-ivory">
|
<div>
|
||||||
<InlineEdit
|
{/* Top Row: Index Badge */}
|
||||||
value={item.title}
|
<div className="flex items-center justify-between mb-2">
|
||||||
onSave={(v) => saveItem(item.id, { title: v })}
|
<span className="text-[11px] font-mono font-bold text-copper tracking-wider px-1.5 py-0.5 rounded bg-copper/10 border border-copper/20">
|
||||||
/>
|
{`0${i + 1}`}
|
||||||
</h3>
|
</span>
|
||||||
<p className="mt-3 text-sm font-light leading-8 text-ivory/55">
|
</div>
|
||||||
<InlineEdit
|
|
||||||
multiline
|
{/* Title */}
|
||||||
value={item.desc}
|
<h4 className="text-xs font-bold text-ivory mb-1 line-clamp-1 leading-snug">
|
||||||
onSave={(v) => saveItem(item.id, { desc: v })}
|
<InlineEdit
|
||||||
/>
|
value={item.title}
|
||||||
</p>
|
onSave={(v) => saveItem(item.id, { title: v })}
|
||||||
</div>
|
/>
|
||||||
</EditableItem>
|
</h4>
|
||||||
</Reveal>
|
|
||||||
)}
|
{/* Description */}
|
||||||
|
<p className="text-[10px] text-ivory/60 leading-relaxed line-clamp-2">
|
||||||
|
<InlineEdit
|
||||||
|
multiline
|
||||||
|
value={item.desc}
|
||||||
|
onSave={(v) => saveItem(item.id, { desc: v })}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</EditableItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-6">
|
||||||
|
<AddItemButton resource="why_us" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-8">
|
|
||||||
<AddItemButton resource="why_us" />
|
{/* Desktop Grid Layout */}
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<div className="grid gap-x-10 gap-y-px sm:grid-cols-2">
|
||||||
|
{why_us.map((item, i) => (
|
||||||
|
<Reveal key={item.id} delay={0.05 * i}>
|
||||||
|
<EditableItem resource="why_us" item={item}>
|
||||||
|
<div className="flex h-full flex-col border-t border-ivory/10 py-8">
|
||||||
|
<span className="text-xs font-light text-copper">{formatIndex(i)}</span>
|
||||||
|
<h3 className="mt-4 text-lg font-bold text-ivory">
|
||||||
|
<InlineEdit
|
||||||
|
value={item.title}
|
||||||
|
onSave={(v) => saveItem(item.id, { title: v })}
|
||||||
|
/>
|
||||||
|
</h3>
|
||||||
|
<p className="mt-3 text-sm font-light leading-8 text-ivory/55">
|
||||||
|
<InlineEdit
|
||||||
|
multiline
|
||||||
|
value={item.desc}
|
||||||
|
onSave={(v) => saveItem(item.id, { desc: v })}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</EditableItem>
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-8">
|
||||||
|
<AddItemButton resource="why_us" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>);
|
</section>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,66 +32,71 @@ type CsvRow = { name: string; year: string; isNew: boolean; lat: number; lng: nu
|
|||||||
* description نگاشت شده است. ترتیب: name,year,new,lat,lng,description
|
* description نگاشت شده است. ترتیب: name,year,new,lat,lng,description
|
||||||
*/
|
*/
|
||||||
const RAW_CSV = `name,year,new,lat,lng,description
|
const RAW_CSV = `name,year,new,lat,lng,description
|
||||||
پروژه سروش,1381,no,32.6662842,51.701735,
|
پروژه سروش,1381,no,32.6662842,51.701735
|
||||||
چهرازی-زیتون,1383,no,32.6700428,51.69985,
|
چهرازی-زیتون,1383,no,32.6700428,51.69985
|
||||||
جعفریان,1384,no,32.6669259,51.701085,
|
جعفریان,1384,no,32.6669259,51.701085
|
||||||
بهار,1385,yes,32.6468963,51.6899119,
|
بهار,1385,no,32.6468963,51.6899119
|
||||||
حسن مرادی,1385,no,32.6704278,51.6985567,
|
حسن مرادی,1385,no,32.6704278,51.6985567
|
||||||
کوثر,1386,no,32.6522044,51.6856474,
|
کوثر- هشت بهشت غربی,1386,no,32.6522044,51.6856474
|
||||||
صبا,1387,no,32.6541363,51.6847828,
|
صبا,1387,no,32.6541363,51.6847828
|
||||||
پورمعالی - بهشت,1387,no,32.6500402,51.6919823,
|
شریف واقفی - بهشت,1387,no,32.6500402,51.6919823
|
||||||
2 پروژه- فردوس,1388,no,32.6614576,51.706429,
|
پروژه- فردوس,1388,no,32.6614576,51.706429
|
||||||
ارم,1388,no,32.6563712,51.710694,
|
ارم,1388,no,32.6563712,51.710694
|
||||||
سینا,1388,no,32.6469299,51.6855403,
|
سینا,1388,no,32.6469299,51.6855403
|
||||||
مسجد سید,1389,yes,32.6669953,51.6557085,
|
مسجد سید,1389,no,32.6669953,51.6557085
|
||||||
هفده شهریور,1389,yes,32.7249998,51.6543819,
|
هفده شهریور,1389,no,32.7249998,51.6543819
|
||||||
آرامش رفیع زاده,1390,no,32.6467107,51.6873398,
|
آرامش رفیع زاده,1390,no,32.6467107,51.6873398
|
||||||
بن بست شریعت,1390,yes,32.6459939,51.6897556,
|
باهنر- ارشاد,1391,no,32.6822102,51.6690006
|
||||||
قصر - یاسین,1390,no,32.6663549,51.7065512,
|
بن بست شریعت,1390,no,32.6459939,51.6897556
|
||||||
پردیس,1390,no,32.6518961,51.6973621,
|
قصر - یاسین,1389,no,32.6663549,51.7065512
|
||||||
آرامش حکیم,1392,no,32.6426783,51.6813477,
|
پردیس,1390,no,32.6518961,51.6973621
|
||||||
آرامش دخانیات,1393,no,32.6411924,51.6797555,
|
آرامش حکیم,1392,no,32.6426783,51.6813477
|
||||||
بهشتی فرشادی,1393,no,32.6531989,51.6790984,
|
آرامش- ادیب,1387,no,32.6789359,51.6771241
|
||||||
کاکتوس,1394,no,32.6486747,51.6859003,
|
آرامش دخانیات,1393,no,32.6411924,51.6797555
|
||||||
آرامش - صیاد,1395,no,32.6486798,51.712659,
|
بهشتی فرشادی,1393,no,32.6531989,51.6790984
|
||||||
کوثر,1395,yes,32.6506204,51.6344053,
|
کاکتوس- شریف واقفی,1394,no,32.6486747,51.6859003
|
||||||
آرامش - بوعلی 1,1396,no,32.6455156,51.6918424,
|
آرامش - صیاد,1395,no,32.6486798,51.712659
|
||||||
میرزا نصیر,1396,no,32.668195,51.7173079,
|
کوثر- میرزاطاهر,1395,no,32.6506204,51.6344053
|
||||||
نیکان,1396,yes,32.6504663,51.6355982,
|
آرامش - بوعلی 1,1396,no,32.6455156,51.6918424
|
||||||
آرامش-رسا,1397,no,32.6522126,51.6857297,
|
آرامش - میرزا نصیر,1396,no,32.668195,51.7173079
|
||||||
نگین,1397,yes,32.6507084,51.6313395,
|
نیکان میرزاطاهر,1396,no,32.6504663,51.6355982
|
||||||
آرامش - بوعلی 2,1398,no,32.645391,51.6917828,
|
آرامش-رسا,1397,no,32.6522126,51.6857297
|
||||||
آرامش استاد همایی,1398,no,32.6563505,51.691988,
|
نگین- میرزاطاهر,1393,no,32.6507084,51.6313395
|
||||||
آرامش صدر,1398,no,32.6469234,51.6826176,
|
آرامش - بوعلی 2,1398,no,32.645391,51.6917828
|
||||||
آرامش - سپهسالار,1399,yes,32.6259658,51.7080268,
|
آرامش استاد همایی,1398,no,32.6563505,51.691988
|
||||||
آرامش- صدر,1399,no,32.6469205,51.6829887,
|
آرامش- صدر,1399,no,32.6469234,51.6826176
|
||||||
آرامش - سنایی,1400,yes,32.6973339,51.6194128,
|
آرامش - سپهسالار,1399,no,32.6259658,51.7080268
|
||||||
مغازه لباس فروشی,1400,no,32.6516965,51.7020497,
|
آرامش - سنایی,1400,no,32.6973339,51.6194128
|
||||||
آرامش - آتشگاه,1401,yes,32.6547584,51.6319244,
|
مغازه لباس فروشی,1400,no,32.6516965,51.7020497
|
||||||
آرامش - بزرگمهر,1401,no,32.651381,51.6993575,
|
آرامش - آتشگاه,1401,no,32.6547584,51.6319244
|
||||||
آرامش - سعدی,1401,yes,32.6970115,51.6196909,
|
آرامش - بزرگمهر,1401,no,32.651381,51.6993575
|
||||||
آرامش - صمدیه لباف,1401,yes,32.6716585,51.6359248,
|
آرامش - سعدی,1401,no,32.6970115,51.6196909
|
||||||
آرامش 2,1401,yes,32.6505704,51.6337863,
|
آرامش - صمدیه لباف,1401,no,32.6716585,51.6359248
|
||||||
آرامش 5 رمضان,1401,yes,32.6674407,51.6568827,
|
آرامش میرزا طاهر 2,1401,no,32.6505704,51.6337863
|
||||||
آرامش احمدآباد,1401,no,32.6626101,51.6969828,
|
آرامش 5 رمضان,1401,no,32.6674407,51.6568827
|
||||||
تجاری آتشگاه,1401,yes,32.6515087,51.6033278,
|
آرامش احمدآباد,1401,no,32.6626101,51.6969828
|
||||||
آرامش - شهیدان,1402,yes,32.6854968,51.6171964,
|
باهنر- شادی,1390,no,32.6824682,51.6670556
|
||||||
آرامش خواجو,1402,no,32.6419187,51.6831873,
|
تجاری آتشگاه,1401,no,32.6515087,51.6033278
|
||||||
آرامش- اداری ابوالحسن,1402,no,32.6453749,51.6915519,
|
آرامش - شهیدان,1402,no,32.6854968,51.6171964
|
||||||
آرامش - انقلاب,1403,yes,32.719769,51.645921,
|
آرامش خواجو,1402,no,32.6419187,51.6831873
|
||||||
آرامش - شریف,1404,no,32.6473319,51.6955996,
|
آرامش- اداری ابوالحسن,1402,no,32.6453749,51.6915519
|
||||||
آرامش - کهندژ,1404,yes,32.6649497,51.630077,
|
آرامش - انقلاب اسلامی,1403,no,32.719769,51.645921
|
||||||
آرامش جی,1404,no,32.6610717,51.7110184,
|
آرامش - شریف,1404,no,32.6473319,51.6955996
|
||||||
آرامش - بابک,1405,yes,32.674076,51.6368204,
|
آرامش - کهندژ,1404,no,32.6649497,51.630077
|
||||||
آرامش - شمس آبادی,1405,yes,32.6560844,51.664771,
|
آرامش جی,1404,no,32.6610717,51.7110184
|
||||||
آرامش - میرزاطاهر 3,1405,yes,32.6495532,51.6178643,
|
آرامش زنیث,1405,no,32.674076,51.6368204
|
||||||
آرامش - وحدت,1405,yes,32.6537564,51.6197007,
|
آرامش - شمس آبادی,1405,no,32.6560844,51.664771
|
||||||
آرامش پروین,1405,no,32.6618223,51.7098152,
|
آرامش الیسیان,1405,no,32.6495532,51.6178643
|
||||||
خلیفه سلطانی,1405,yes,32.7035398,51.6223296,
|
آرامش - وحدت,1405,no,32.6537564,51.6197007
|
||||||
آرامش - میرزا طاهر 5,1406,yes,32.6517244,51.6202975,
|
آرامش پروین,1405,no,32.6618223,51.7098152
|
||||||
آرامش - میرزاطاهر 6,1408,yes,32.6505366,51.6267342,
|
خلیفه سلطانی,1405,no,32.7035398,51.6223296
|
||||||
آرامش - میرزا طاهر 4,,yes,32.6510328,51.631436,
|
آرامش آوالون,1406,no,32.6517244,51.6202975
|
||||||
آرامش- میرزا طاهر 1,,yes,32.6506236,51.6298238,`
|
آرامش - میرزا طاهر 4,,no,32.6510328,51.631436
|
||||||
|
آرامش- میرزا طاهر 1,1396,no,32.6506236,51.6298238
|
||||||
|
آرامش لکسون,1406,no,32.60600917636113,51.67503993694544
|
||||||
|
ارغوانیه,1395,no,32.64448932441766,51.77707692162298
|
||||||
|
آرامش- ابوریحان,1405,no,32.700027968614734,51.71497771552572
|
||||||
|
`
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* تجزیهی یک خط CSV — پشتیبانی از فیلدهای گیومهدار و گیومهی دوگانهی درونی.
|
* تجزیهی یک خط CSV — پشتیبانی از فیلدهای گیومهدار و گیومهی دوگانهی درونی.
|
||||||
@@ -140,14 +145,13 @@ export function parseProjectsCsv(raw: string): MapProject[] {
|
|||||||
|
|
||||||
export const MAP_PROJECTS: MapProject[] = parseProjectsCsv(RAW_CSV)
|
export const MAP_PROJECTS: MapProject[] = parseProjectsCsv(RAW_CSV)
|
||||||
|
|
||||||
/** پیوند نشان روی نقشه برای سرویس نشان */
|
/**
|
||||||
export function buildNeshanUrl(project: Pick<MapProject, 'lat' | 'lng'>, zoom = 17): string {
|
* پیوند نقشهٔ گوگل روی مختصات پروژه — قالب رسمی Maps URLs با پارامتر query،
|
||||||
return `https://neshan.org/maps/@${project.lat},${project.lng},${zoom}z`
|
* بینیاز از کلید API. یک سنجاق دقیق روی همان نقطه مینشاند و روی موبایل
|
||||||
}
|
* اپلیکیشن گوگلمپ را باز میکند.
|
||||||
|
*/
|
||||||
/** پیوند نقشه گوگل */
|
|
||||||
export function buildGoogleMapsUrl(project: Pick<MapProject, 'lat' | 'lng'>): string {
|
export function buildGoogleMapsUrl(project: Pick<MapProject, 'lat' | 'lng'>): string {
|
||||||
return `https://www.google.com/maps?q=${project.lat},${project.lng}`
|
return `https://www.google.com/maps/search/?api=1&query=${project.lat},${project.lng}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const NAME_NOISE = ['آرامش', 'پروژه', 'مجتمع', 'گروه', 'ساختمانی', 'برج', 'مجموعه']
|
const NAME_NOISE = ['آرامش', 'پروژه', 'مجتمع', 'گروه', 'ساختمانی', 'برج', 'مجموعه']
|
||||||
@@ -227,7 +231,7 @@ export function csvProjectToCard(project: MapProject, detailHref: string): Proje
|
|||||||
title: project.name,
|
title: project.name,
|
||||||
status: project.isNew ? 'progress' : 'done',
|
status: project.isNew ? 'progress' : 'done',
|
||||||
detailHref,
|
detailHref,
|
||||||
addressUrl: buildNeshanUrl(project),
|
addressUrl: buildGoogleMapsUrl(project),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,6 +246,6 @@ export function apiProjectToCard(project: Project, detailHref: string): ProjectC
|
|||||||
location: project.location || undefined,
|
location: project.location || undefined,
|
||||||
image: project.image || undefined,
|
image: project.image || undefined,
|
||||||
area: project.area || undefined,
|
area: project.area || undefined,
|
||||||
addressUrl: matched ? buildNeshanUrl(matched) : undefined,
|
addressUrl: matched ? buildGoogleMapsUrl(matched) : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export type ArticleStatus = 'draft' | 'published';
|
|||||||
|
|
||||||
export type Article = {
|
export type Article = {
|
||||||
id: number;
|
id: number;
|
||||||
slug: string; title: string; title_en?: string; date: string; date_en?: string; category: string; category_en?: string;
|
slug: string; slug_en?: string; title: string; title_en?: string; date: string; date_en?: string; category: string; category_en?: string;
|
||||||
image: string;
|
image: string;
|
||||||
read: string; read_en?: string; excerpt: string; excerpt_en?: string;
|
read: string; read_en?: string; excerpt: string; excerpt_en?: string;
|
||||||
/** پرسشوپاسخهای پایان مقاله — سئوی FAQPage (faqs_en خالی → برگشت به فارسی) */
|
/** پرسشوپاسخهای پایان مقاله — سئوی FAQPage (faqs_en خالی → برگشت به فارسی) */
|
||||||
@@ -63,16 +63,20 @@ export type Article = {
|
|||||||
status: ArticleStatus;
|
status: ArticleStatus;
|
||||||
/** کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود */
|
/** کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود */
|
||||||
seo_keyphrase: string;
|
seo_keyphrase: string;
|
||||||
|
seo_keyphrase_en?: string;
|
||||||
/** توضیحات متای سئو — در اولویت بر چکیده برای تگ description */
|
/** توضیحات متای سئو — در اولویت بر چکیده برای تگ description */
|
||||||
meta_description?: string;
|
meta_description?: string;
|
||||||
|
meta_description_en?: string;
|
||||||
/** متن جایگزین تصویر شاخص (Alt) */
|
/** متن جایگزین تصویر شاخص (Alt) */
|
||||||
image_alt?: string;
|
image_alt?: string;
|
||||||
/** عنوان تصویر شاخص (Title) */
|
/** عنوان تصویر شاخص (Title) */
|
||||||
image_title?: string;
|
image_title?: string;
|
||||||
/** نویسنده مقاله — در سربرگ مقاله زیر عنوان نمایش داده میشود */
|
/** نویسنده مقاله — در سربرگ مقاله زیر عنوان نمایش داده میشود */
|
||||||
author?: string;
|
author?: string;
|
||||||
|
author_en?: string;
|
||||||
/** هشتگها/برچسبهای مقاله */
|
/** هشتگها/برچسبهای مقاله */
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
tags_en?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ export const fa = {
|
|||||||
switchLang: 'English',
|
switchLang: 'English',
|
||||||
switchLangAria: 'تغییر زبان به انگلیسی',
|
switchLangAria: 'تغییر زبان به انگلیسی',
|
||||||
understood: 'متوجه شدم',
|
understood: 'متوجه شدم',
|
||||||
|
paginationAria: 'صفحهبندی پروژهها',
|
||||||
|
prevProject: 'پروژهٔ قبلی',
|
||||||
|
nextProject: 'پروژهٔ بعدی',
|
||||||
|
projectNavAria: 'پیمایش بین پروژهها',
|
||||||
|
previousPage: 'صفحهٔ قبل',
|
||||||
|
nextPage: 'صفحهٔ بعد',
|
||||||
|
|
||||||
// هدر و ناوبری
|
// هدر و ناوبری
|
||||||
mainNav: 'ناوبری اصلی',
|
mainNav: 'ناوبری اصلی',
|
||||||
@@ -84,6 +90,9 @@ export const fa = {
|
|||||||
relatedArticlesAria: 'مقالات مرتبط',
|
relatedArticlesAria: 'مقالات مرتبط',
|
||||||
relatedArticles: 'مقالات مرتبط',
|
relatedArticles: 'مقالات مرتبط',
|
||||||
readArticlePrefix: 'خواندن مقاله',
|
readArticlePrefix: 'خواندن مقاله',
|
||||||
|
author: 'نویسنده',
|
||||||
|
tableOfContents: 'فهرست مطالب',
|
||||||
|
aboutUs: 'درباره ما',
|
||||||
articleImagePrefix: 'تصویر مقاله',
|
articleImagePrefix: 'تصویر مقاله',
|
||||||
featuredArticle: 'مقاله ویژه',
|
featuredArticle: 'مقاله ویژه',
|
||||||
readMore: 'ادامه مطلب',
|
readMore: 'ادامه مطلب',
|
||||||
@@ -138,6 +147,12 @@ export const en: Dict = {
|
|||||||
switchLang: 'فا',
|
switchLang: 'فا',
|
||||||
switchLangAria: 'Switch language to Persian (Farsi)',
|
switchLangAria: 'Switch language to Persian (Farsi)',
|
||||||
understood: 'Got it',
|
understood: 'Got it',
|
||||||
|
paginationAria: 'Projects pagination',
|
||||||
|
prevProject: 'Previous project',
|
||||||
|
nextProject: 'Next project',
|
||||||
|
projectNavAria: 'Browse projects',
|
||||||
|
previousPage: 'Previous page',
|
||||||
|
nextPage: 'Next page',
|
||||||
|
|
||||||
mainNav: 'Main navigation',
|
mainNav: 'Main navigation',
|
||||||
mobileNav: 'Mobile navigation',
|
mobileNav: 'Mobile navigation',
|
||||||
@@ -204,6 +219,9 @@ export const en: Dict = {
|
|||||||
relatedArticlesAria: 'Related articles',
|
relatedArticlesAria: 'Related articles',
|
||||||
relatedArticles: 'Related articles',
|
relatedArticles: 'Related articles',
|
||||||
readArticlePrefix: 'Read article',
|
readArticlePrefix: 'Read article',
|
||||||
|
author: 'Author',
|
||||||
|
tableOfContents: 'Table of Contents',
|
||||||
|
aboutUs: 'About Us',
|
||||||
articleImagePrefix: 'Image of article',
|
articleImagePrefix: 'Image of article',
|
||||||
featuredArticle: 'Featured article',
|
featuredArticle: 'Featured article',
|
||||||
readMore: 'Read more',
|
readMore: 'Read more',
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
// فرانتاند هر دو زبان را در یک درخواست از GET /api/site میگیرد و سوییچ زبان
|
// فرانتاند هر دو زبان را در یک درخواست از GET /api/site میگیرد و سوییچ زبان
|
||||||
// بیدرنگ و بدون درخواست تازه انجام میشود. داده خام برای پنل مدیریت دستنخورده میماند.
|
// بیدرنگ و بدون درخواست تازه انجام میشود. داده خام برای پنل مدیریت دستنخورده میماند.
|
||||||
|
|
||||||
import type { Article, ArticleBodyDoc } from '../data/site'
|
import type { Article } from '../data/site'
|
||||||
import type { SiteData } from '../api'
|
import type { SiteData } from '../api'
|
||||||
import type { Lang } from './lang'
|
import type { Lang } from './lang'
|
||||||
|
import { docHasText } from '../utils/tiptapDoc'
|
||||||
|
|
||||||
/** متن انگلیسی فقط وقتی جایگزین میشود که واقعاً چیزی نوشته شده باشد */
|
/** متن انگلیسی فقط وقتی جایگزین میشود که واقعاً چیزی نوشته شده باشد */
|
||||||
const s = (fa: string, en?: string | null): string => (en && en.trim() ? en : fa)
|
const s = (fa: string, en?: string | null): string => (en && en.trim() ? en : fa)
|
||||||
@@ -12,28 +13,13 @@ const s = (fa: string, en?: string | null): string => (en && en.trim() ? en : fa
|
|||||||
/** لیست انگلیسی فقط وقتی جایگزین میشود که غیرخالی باشد */
|
/** لیست انگلیسی فقط وقتی جایگزین میشود که غیرخالی باشد */
|
||||||
const list = <T,>(fa: T[], en?: T[] | null): T[] => (en && en.length ? en : fa)
|
const list = <T,>(fa: T[], en?: T[] | null): T[] => (en && en.length ? en : fa)
|
||||||
|
|
||||||
/** سند TipTap انگلیسی فقط وقتی متن واقعی دارد جایگزین بدنه فارسی میشود */
|
|
||||||
function docHasText(doc: unknown): boolean {
|
|
||||||
if (!doc || typeof doc !== 'object') return false
|
|
||||||
const content = (doc as ArticleBodyDoc).content
|
|
||||||
if (!Array.isArray(content)) return false
|
|
||||||
let found = false
|
|
||||||
const walk = (node: unknown): void => {
|
|
||||||
if (!node || typeof node !== 'object' || found) return
|
|
||||||
const n = node as { type?: string; text?: string; content?: unknown[] }
|
|
||||||
if (n.type === 'text' && typeof n.text === 'string' && n.text.trim()) found = true
|
|
||||||
n.content?.forEach(walk)
|
|
||||||
}
|
|
||||||
content.forEach(walk)
|
|
||||||
return found
|
|
||||||
}
|
|
||||||
|
|
||||||
export function localizeSite(data: SiteData, lang: Lang): SiteData {
|
export function localizeSite(data: SiteData, lang: Lang): SiteData {
|
||||||
if (lang === 'fa') return data
|
if (lang === 'fa') return data
|
||||||
return {
|
return {
|
||||||
brand: {
|
brand: {
|
||||||
...data.brand,
|
...data.brand,
|
||||||
name: s(data.brand.name, data.brand.name_en),
|
name: s(data.brand.name, data.brand.name_en),
|
||||||
|
logo: s(data.brand.logo, data.brand.logo_en),
|
||||||
address: s(data.brand.address, data.brand.address_en),
|
address: s(data.brand.address, data.brand.address_en),
|
||||||
},
|
},
|
||||||
nav: data.nav.map((n) => ({ ...n, label: s(n.label, n.label_en) })),
|
nav: data.nav.map((n) => ({ ...n, label: s(n.label, n.label_en) })),
|
||||||
@@ -93,13 +79,17 @@ export function localizeSite(data: SiteData, lang: Lang): SiteData {
|
|||||||
})),
|
})),
|
||||||
articles: data.articles.map((a: Article) => ({
|
articles: data.articles.map((a: Article) => ({
|
||||||
...a,
|
...a,
|
||||||
|
slug: s(a.slug, a.slug_en),
|
||||||
title: s(a.title, a.title_en),
|
title: s(a.title, a.title_en),
|
||||||
date: s(a.date, a.date_en),
|
date: s(a.date, a.date_en),
|
||||||
category: s(a.category, a.category_en),
|
category: s(a.category, a.category_en),
|
||||||
read: s(a.read, a.read_en),
|
read: s(a.read, a.read_en),
|
||||||
|
author: s(a.author ?? '', a.author_en),
|
||||||
excerpt: s(a.excerpt, a.excerpt_en),
|
excerpt: s(a.excerpt, a.excerpt_en),
|
||||||
|
meta_description: s(a.meta_description ?? '', a.meta_description_en),
|
||||||
body: docHasText(a.body_en) ? a.body_en! : a.body,
|
body: docHasText(a.body_en) ? a.body_en! : a.body,
|
||||||
faqs: list(a.faqs ?? [], a.faqs_en),
|
faqs: list(a.faqs ?? [], a.faqs_en),
|
||||||
|
tags: list(a.tags ?? [], a.tags_en),
|
||||||
})),
|
})),
|
||||||
testimonials: data.testimonials.map((tm) => ({
|
testimonials: data.testimonials.map((tm) => ({
|
||||||
...tm,
|
...tm,
|
||||||
|
|||||||
+97
-2
@@ -7,6 +7,12 @@
|
|||||||
|
|
||||||
/* END TAILWIND IMPORTS — ALL OTHER CSS MUST GO BELOW THIS LINE */
|
/* END TAILWIND IMPORTS — ALL OTHER CSS MUST GO BELOW THIS LINE */
|
||||||
|
|
||||||
|
/* CSS پایهٔ مپلیبر در شیت اصلی میماند (نه در چانک تنبل نقشه).
|
||||||
|
اگر با خودِ کامپوننت نقشه بار شود، روی موبایل/اتصال کند ممکن است کد نقشه
|
||||||
|
پیش از رسیدن این فایل اجرا شود و .maplibregl-map (position/overflow) اعمال
|
||||||
|
نشود — نتیجه: ظرف با ارتفاع صفر و نقشهٔ نامرئی. */
|
||||||
|
@import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
|
|
||||||
/* فونت سایت سلفهاست است (public/fonts) — هیچ وابستگی خارجی وجود ندارد.
|
/* فونت سایت سلفهاست است (public/fonts) — هیچ وابستگی خارجی وجود ندارد.
|
||||||
Pinar فونت اصلی است (۵ وزن) و Vazirmatn بهعنوان جایگزین پشتیبان میماند.
|
Pinar فونت اصلی است (۵ وزن) و Vazirmatn بهعنوان جایگزین پشتیبان میماند.
|
||||||
نسخه انگلیسی از پشته فونت سیستمی لاتین استفاده میکند (Inter در صورت نصب بودن). */
|
نسخه انگلیسی از پشته فونت سیستمی لاتین استفاده میکند (Inter در صورت نصب بودن). */
|
||||||
@@ -77,16 +83,33 @@ html[lang='en'] body {
|
|||||||
font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
|
font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
/* حرف Q در فونت پینار بهصورت O ترسیم شده (نقص خود فونت) — روی نسخه انگلیسی
|
||||||
|
تمام عناصرِ font-pinar هم به فونت لاتین میروند تا Q درست رندر شود. */
|
||||||
|
html[lang='en'] .font-pinar {
|
||||||
|
font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
|
||||||
|
}
|
||||||
html[lang='en'] .article-body {
|
html[lang='en'] .article-body {
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
|
font-size: 17px; /* وبلاگ انگلیسی دو سه درجه بزرگتر از فارسی */
|
||||||
}
|
}
|
||||||
html[lang='en'] .article-body p {
|
html[lang='en'] .article-body p {
|
||||||
margin-top: 1.25rem;
|
margin-top: 1.25rem;
|
||||||
}
|
}
|
||||||
|
html[lang='en'] .article-body h1,
|
||||||
html[lang='en'] .article-body h2,
|
html[lang='en'] .article-body h2,
|
||||||
html[lang='en'] .article-body h3 {
|
html[lang='en'] .article-body h3 {
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
/* هدینگها نسبت به متن ۱۷px انگلیسی بزرگتر — همان نسبت نسخه فارسی */
|
||||||
|
html[lang='en'] .article-body h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
html[lang='en'] .article-body h2 {
|
||||||
|
font-size: 19px;
|
||||||
|
}
|
||||||
|
html[lang='en'] .article-body h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
::selection {
|
::selection {
|
||||||
background-color: #d5874c;
|
background-color: #d5874c;
|
||||||
color: #1e1a16;
|
color: #1e1a16;
|
||||||
@@ -101,7 +124,7 @@ html[lang='en'] .article-body h3 {
|
|||||||
.article-body {
|
.article-body {
|
||||||
line-height: 2.4;
|
line-height: 2.4;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
font-size: 14px; /* متن اصلی ۱۴ تنظیم شد */
|
font-size: 15px; /* متن اصلی — یک درجه بزرگتر از قبل */
|
||||||
}
|
}
|
||||||
.article-body p {
|
.article-body p {
|
||||||
margin-top: 1.75rem;
|
margin-top: 1.75rem;
|
||||||
@@ -226,10 +249,18 @@ html[lang='en'] .article-body h3 {
|
|||||||
margin-top: 0.6rem;
|
margin-top: 0.6rem;
|
||||||
color: rgba(245, 243, 239, 0.7);
|
color: rgba(245, 243, 239, 0.7);
|
||||||
}
|
}
|
||||||
.article-body li p {
|
.article-body li > p {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
/* نقلقول و هر بلاک تو در تو داخل آیتم فهرست — بلاکی بماند تا پاراگرافها یکی نشوند */
|
||||||
|
.article-body li blockquote p {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.article-body li blockquote p + p {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
.article-body blockquote {
|
.article-body blockquote {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
border-inline-start: 3px solid #d5874c;
|
border-inline-start: 3px solid #d5874c;
|
||||||
@@ -242,6 +273,15 @@ html[lang='en'] .article-body h3 {
|
|||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
/* پاراگرافهای داخل نقلقول (متن/شخص/نقش) از هم جدا دیده شوند — بدون این،
|
||||||
|
چند پاراگراف پشتسرهم یک بلاک یکسره به نظر میرسند */
|
||||||
|
.article-body blockquote p + p {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
/* پاراگرافهای خالیای که موقع تایپ نقلقول جا میمانند — خط خالی اضافه رندر نکنند */
|
||||||
|
.article-body blockquote p:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.article-body a {
|
.article-body a {
|
||||||
color: #d5874c;
|
color: #d5874c;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
@@ -472,3 +512,58 @@ html[lang='en'] .article-body h3 {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* ---------- جدول در بدنه مقاله و ویرایشگر ---------- */
|
||||||
|
.article-body table,
|
||||||
|
.ProseMirror table {
|
||||||
|
width: 100%;
|
||||||
|
table-layout: fixed;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
font-size: 0.92em;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ProseMirror td,
|
||||||
|
.ProseMirror th {
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.ProseMirror .column-resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: -1px;
|
||||||
|
left: -2px;
|
||||||
|
width: 4px;
|
||||||
|
z-index: 20;
|
||||||
|
background-color: rgba(213, 135, 76, 0.6);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.ProseMirror.resize-cursor {
|
||||||
|
cursor: col-resize;
|
||||||
|
}
|
||||||
|
.ProseMirror .selectedCell::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(213, 135, 76, 0.12);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.article-body th,
|
||||||
|
.article-body td,
|
||||||
|
.ProseMirror th,
|
||||||
|
.ProseMirror td {
|
||||||
|
border: 1px solid rgba(245, 243, 239, 0.18);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
text-align: start;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.article-body th,
|
||||||
|
.ProseMirror th {
|
||||||
|
background: rgba(213, 135, 76, 0.12);
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f5f3ef;
|
||||||
|
}
|
||||||
|
.ProseMirror td > p,
|
||||||
|
.ProseMirror th > p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { lazy, Suspense } from 'react'
|
import { Component, lazy, Suspense } from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
import type { ProjectsMapAllProps } from './ProjectsMapAll'
|
import type { ProjectsMapAllProps } from './ProjectsMapAll'
|
||||||
import type { ProjectsMapSingleProps } from './ProjectsMapSingle'
|
import type { ProjectsMapSingleProps } from './ProjectsMapSingle'
|
||||||
|
|
||||||
@@ -17,18 +18,55 @@ function MapSkeleton({ className = '' }: { className?: string }) {
|
|||||||
return <div className={`animate-pulse rounded-3xl border border-ivory/10 bg-char/50 ${className}`} />
|
return <div className={`animate-pulse rounded-3xl border border-ivory/10 bg-char/50 ${className}`} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* مرز خطای نقشه — تبِ باز از پیش از استقرار، تکههای قدیمی را درخواست میکند
|
||||||
|
* که دیگر وجود ندارند (۴۰۴) و نقشه بیصدا میمیرد. این مرز خطا را میگیرد و
|
||||||
|
* بارگیری دوبارهٔ کامل صفحه را پیشنهاد میدهد که تازهترین نسخه را میآورد.
|
||||||
|
*/
|
||||||
|
class MapErrorBoundary extends Component<{ children: ReactNode; className?: string }, { failed: boolean }> {
|
||||||
|
state = { failed: false }
|
||||||
|
static getDerivedStateFromError() {
|
||||||
|
return { failed: true }
|
||||||
|
}
|
||||||
|
render() {
|
||||||
|
if (this.state.failed) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex flex-col items-center justify-center gap-3 rounded-3xl border border-ivory/10 bg-char/50 ${this.props.className ?? ''}`}
|
||||||
|
>
|
||||||
|
<p className="px-6 text-center text-sm font-light text-ivory/60">
|
||||||
|
بارگیری نقشه ناموفق بود — نسخهٔ صفحه در این تب قدیمی است
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="border border-copper/50 px-5 py-2 text-xs font-bold text-copper transition-colors duration-200 hover:bg-copper hover:text-char"
|
||||||
|
>
|
||||||
|
بارگیری دوبارهٔ صفحه
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function LazyProjectsMapAll(props: ProjectsMapAllProps) {
|
export function LazyProjectsMapAll(props: ProjectsMapAllProps) {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<MapSkeleton className={props.className} />}>
|
<MapErrorBoundary className={props.className}>
|
||||||
<ProjectsMapAll {...props} />
|
<Suspense fallback={<MapSkeleton className={props.className} />}>
|
||||||
</Suspense>
|
<ProjectsMapAll {...props} />
|
||||||
|
</Suspense>
|
||||||
|
</MapErrorBoundary>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LazyProjectsMapSingle(props: ProjectsMapSingleProps) {
|
export function LazyProjectsMapSingle(props: ProjectsMapSingleProps) {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<MapSkeleton className={props.className} />}>
|
<MapErrorBoundary className={props.className}>
|
||||||
<ProjectsMapSingle {...props} />
|
<Suspense fallback={<MapSkeleton className={props.className} />}>
|
||||||
</Suspense>
|
<ProjectsMapSingle {...props} />
|
||||||
|
</Suspense>
|
||||||
|
</MapErrorBoundary>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import * as maplibregl from 'maplibre-gl'
|
|||||||
import { useEffect, useState, type MutableRefObject, type ReactNode } from 'react'
|
import { useEffect, useState, type MutableRefObject, type ReactNode } from 'react'
|
||||||
import { Loader2Icon } from 'lucide-react'
|
import { Loader2Icon } from 'lucide-react'
|
||||||
import { MdClose, MdFullscreen } from 'react-icons/md'
|
import { MdClose, MdFullscreen } from 'react-icons/md'
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
|
||||||
import { useLang } from '../i18n/lang'
|
import { useLang } from '../i18n/lang'
|
||||||
import type { MapStatus } from './types'
|
import type { MapStatus } from './types'
|
||||||
|
|
||||||
@@ -67,7 +66,7 @@ export function MapViewport({
|
|||||||
|
|
||||||
const shellClass = isFullscreen
|
const shellClass = isFullscreen
|
||||||
? 'fixed inset-0 z-[75] h-[100dvh] w-screen animate-[luxFadeIn_0.25s_ease-out] rounded-none border-0'
|
? 'fixed inset-0 z-[75] h-[100dvh] w-screen animate-[luxFadeIn_0.25s_ease-out] rounded-none border-0'
|
||||||
: `relative h-full w-full rounded-3xl border border-ivory/10 shadow-lift ${className}`
|
: `relative h-full min-h-[280px] w-full rounded-3xl border border-ivory/10 shadow-lift ${className}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -79,7 +78,7 @@ export function MapViewport({
|
|||||||
است تا هم بستر موقعیتیابی فرزندان مپلیبر باشد و هم هشدار
|
است تا هم بستر موقعیتیابی فرزندان مپلیبر باشد و هم هشدار
|
||||||
«container has a non-static position» و محاسبهی اشتباه آفست اسکرول
|
«container has a non-static position» و محاسبهی اشتباه آفست اسکرول
|
||||||
رخ ندهد — ترتیب بار شدن CSS هیچوقت تعیینکننده نباشد */}
|
رخ ندهد — ترتیب بار شدن CSS هیچوقت تعیینکننده نباشد */}
|
||||||
<div ref={containerRef} className="relative h-full w-full" />
|
<div ref={containerRef} className="relative h-full min-h-[280px] w-full" />
|
||||||
|
|
||||||
{isFullscreen ? (
|
{isFullscreen ? (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ type ProjectImageSliderProps = {
|
|||||||
images: string[]
|
images: string[]
|
||||||
alt: string
|
alt: string
|
||||||
className?: string
|
className?: string
|
||||||
|
/** در نبود تصویر، بهجای جاینگهدار کل قاب پنهان شود (صفحهای که تصویر را جای دیگر نشان میدهد) */
|
||||||
|
hideWhenEmpty?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,7 +18,7 @@ type ProjectImageSliderProps = {
|
|||||||
* شمارندهٔ اسلاید و گوشههای گرد هماندازهٔ کارت. همهٔ رویدادهای اشاره روی
|
* شمارندهٔ اسلاید و گوشههای گرد هماندازهٔ کارت. همهٔ رویدادهای اشاره روی
|
||||||
* اسلایدر متوقف میشوند تا کلیک/کشیدن آن، نقشه را جابهجا یا پاپآپ را نبندد.
|
* اسلایدر متوقف میشوند تا کلیک/کشیدن آن، نقشه را جابهجا یا پاپآپ را نبندد.
|
||||||
*/
|
*/
|
||||||
export function ProjectImageSlider({ images, alt, className = '' }: ProjectImageSliderProps) {
|
export function ProjectImageSlider({ images, alt, className = '', hideWhenEmpty = false }: ProjectImageSliderProps) {
|
||||||
const { t } = useLang()
|
const { t } = useLang()
|
||||||
const [index, setIndex] = useState(0)
|
const [index, setIndex] = useState(0)
|
||||||
|
|
||||||
@@ -26,6 +28,7 @@ export function ProjectImageSlider({ images, alt, className = '' }: ProjectImage
|
|||||||
const frame = `relative aspect-[16/10] w-full overflow-hidden rounded-t-2xl bg-char ${className}`
|
const frame = `relative aspect-[16/10] w-full overflow-hidden rounded-t-2xl bg-char ${className}`
|
||||||
|
|
||||||
if (!images.length) {
|
if (!images.length) {
|
||||||
|
if (hideWhenEmpty) return null
|
||||||
return (
|
return (
|
||||||
<div className={frame} onClick={stop} onPointerDown={stop} onMouseDown={stop}>
|
<div className={frame} onClick={stop} onPointerDown={stop} onMouseDown={stop}>
|
||||||
<ProjectImagePlaceholder />
|
<ProjectImagePlaceholder />
|
||||||
@@ -41,7 +44,7 @@ export function ProjectImageSlider({ images, alt, className = '' }: ProjectImage
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={frame} onClick={stop} onPointerDown={stop} onMouseDown={stop}>
|
<div className={frame} onClick={stop} onPointerDown={stop} onMouseDown={stop}>
|
||||||
<img src={images[current]} alt={alt} draggable={false} className="h-full w-full object-cover" />
|
<img src={images[current]} alt={alt} draggable={false} className="h-full w-full object-cover" loading="lazy" decoding="async" />
|
||||||
|
|
||||||
{images.length > 1 && (
|
{images.length > 1 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ type ProjectPopupCardProps = {
|
|||||||
project: MapProject
|
project: MapProject
|
||||||
/** تصاویر پروژه — همان آرایهٔ صفحهٔ جزئیات؛ در نبودش جاینگهدار میآید */
|
/** تصاویر پروژه — همان آرایهٔ صفحهٔ جزئیات؛ در نبودش جاینگهدار میآید */
|
||||||
images?: string[]
|
images?: string[]
|
||||||
|
/** در نبود تصویر، قاب تصویر پنهان شود (نه جاینگهدار) — برای صفحهٔ خود پروژه */
|
||||||
|
hideImageWhenEmpty?: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
/** دکمههای پایهٔ کارت — هر نما اکشنهای خودش را میدهد (عمودی روی هم) */
|
/** دکمههای پایهٔ کارت — هر نما اکشنهای خودش را میدهد (عمودی روی هم) */
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
@@ -25,7 +27,7 @@ type ProjectPopupCardProps = {
|
|||||||
* عنوان و توضیح، و در پایه دکمههای عمودی. راستبهچپ، شیشهای تیره و
|
* عنوان و توضیح، و در پایه دکمههای عمودی. راستبهچپ، شیشهای تیره و
|
||||||
* هماندازهٔ کارتهای سایت. سال پروژه عمداً نمایش داده نمیشود.
|
* هماندازهٔ کارتهای سایت. سال پروژه عمداً نمایش داده نمیشود.
|
||||||
*/
|
*/
|
||||||
export function ProjectPopupCard({ project, images = [], onClose, children }: ProjectPopupCardProps) {
|
export function ProjectPopupCard({ project, images = [], hideImageWhenEmpty = false, onClose, children }: ProjectPopupCardProps) {
|
||||||
const { t } = useLang()
|
const { t } = useLang()
|
||||||
const description = project.description.trim() || t('mapProjectDescription')
|
const description = project.description.trim() || t('mapProjectDescription')
|
||||||
|
|
||||||
@@ -36,7 +38,7 @@ export function ProjectPopupCard({ project, images = [], onClose, children }: Pr
|
|||||||
>
|
>
|
||||||
{/* اسلایدر تصویر: لبهتالبه، بدون حاشیه، با فلشهای چپ/راست */}
|
{/* اسلایدر تصویر: لبهتالبه، بدون حاشیه، با فلشهای چپ/راست */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<ProjectImageSlider images={images} alt={project.name} />
|
<ProjectImageSlider images={images} alt={project.name} hideWhenEmpty={hideImageWhenEmpty} />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { useMemo } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { MdArrowBack, MdOpenInNew } from 'react-icons/md'
|
import { MdArrowBack, MdOpenInNew } from 'react-icons/md'
|
||||||
import {
|
import {
|
||||||
ISFAHAN_CENTER,
|
ISFAHAN_CENTER,
|
||||||
MAP_PROJECTS,
|
MAP_PROJECTS,
|
||||||
boundsForProjects,
|
boundsForProjects,
|
||||||
buildNeshanUrl,
|
buildGoogleMapsUrl,
|
||||||
type MapProject,
|
type MapProject,
|
||||||
} from '../data/mapProjects'
|
} from '../data/mapProjects'
|
||||||
import { useLang } from '../i18n/lang'
|
import { useLang } from '../i18n/lang'
|
||||||
@@ -32,8 +32,8 @@ export type ProjectsMapAllProps = {
|
|||||||
/**
|
/**
|
||||||
* نمای «همهی پروژهها» — همهی مختصات شیت روی یک نقشهی تمامعرض با
|
* نمای «همهی پروژهها» — همهی مختصات شیت روی یک نقشهی تمامعرض با
|
||||||
* سنجاقهای مسی، پاپآپ کارتی و دو اکشن عمودی: صفحهی پروژه و آدرس نشان.
|
* سنجاقهای مسی، پاپآپ کارتی و دو اکشن عمودی: صفحهی پروژه و آدرس نشان.
|
||||||
* پاپآپ فقط با کلیک کاربر باز میشود و جابهجایی نقشه به محدودهٔ پروژهها
|
* پاپآپ فقط با کلیک کاربر باز میشود؛ نمای نخست همهی سنجاقها را در قاب
|
||||||
* بسته است.
|
* دارد و زوماوت تا دید کشوری آزاد است.
|
||||||
*/
|
*/
|
||||||
export function ProjectsMapAll({
|
export function ProjectsMapAll({
|
||||||
className = '',
|
className = '',
|
||||||
@@ -43,9 +43,21 @@ export function ProjectsMapAll({
|
|||||||
}: ProjectsMapAllProps) {
|
}: ProjectsMapAllProps) {
|
||||||
const { t, href } = useLang()
|
const { t, href } = useLang()
|
||||||
const bounds = useMemo(() => boundsForProjects(projects), [projects])
|
const bounds = useMemo(() => boundsForProjects(projects), [projects])
|
||||||
const { containerRef, map, status, retry } = useMapLibre({ center: ISFAHAN_CENTER, zoom: 12, maxBounds: bounds })
|
// بدون maxBounds — زوماوت آزاد تا کف زوم ۵ (دید کشوری)؛ نمای نخست با fitBounds
|
||||||
|
// همهٔ سنجاقها را در یک قاب مینشیند.
|
||||||
|
const { containerRef, map, status, retry } = useMapLibre({
|
||||||
|
center: ISFAHAN_CENTER,
|
||||||
|
zoom: 12,
|
||||||
|
minZoom: 5,
|
||||||
|
})
|
||||||
const { popupHost, activeProject, closePopup } = useProjectMarkers(map, projects)
|
const { popupHost, activeProject, closePopup } = useProjectMarkers(map, projects)
|
||||||
|
|
||||||
|
// نمای نخست: قاب همهٔ پروژهها — بعد از آمادهشدن نقشه یکبار اجرا میشود
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map || !bounds) return
|
||||||
|
map.fitBounds(bounds, { padding: 48, maxZoom: 14, duration: 0 })
|
||||||
|
}, [map, bounds])
|
||||||
|
|
||||||
const projectHref = href((activeProject && resolveProjectHref?.(activeProject)) || '/projects')
|
const projectHref = href((activeProject && resolveProjectHref?.(activeProject)) || '/projects')
|
||||||
const images = activeProject ? (imagesFor?.(activeProject) ?? []) : []
|
const images = activeProject ? (imagesFor?.(activeProject) ?? []) : []
|
||||||
|
|
||||||
@@ -61,14 +73,14 @@ export function ProjectsMapAll({
|
|||||||
{popupHost &&
|
{popupHost &&
|
||||||
activeProject &&
|
activeProject &&
|
||||||
createPortal(
|
createPortal(
|
||||||
<ProjectPopupCard project={activeProject} images={images} onClose={closePopup}>
|
<ProjectPopupCard project={activeProject} images={images} hideImageWhenEmpty onClose={closePopup}>
|
||||||
<Link to={projectHref} className={POPUP_PRIMARY_CLASS}>
|
<Link to={projectHref} className={POPUP_PRIMARY_CLASS}>
|
||||||
{t('viewProject')}
|
{t('viewProject')}
|
||||||
<MdArrowBack className="h-4 w-4" aria-hidden="true" />
|
<MdArrowBack className="h-4 w-4" aria-hidden="true" />
|
||||||
</Link>
|
</Link>
|
||||||
{/* منطق دکمهٔ آدرس دستنخورده — فقط در ستون عمودی نشسته است */}
|
{/* منطق دکمهٔ آدرس دستنخورده — فقط در ستون عمودی نشسته است */}
|
||||||
<a
|
<a
|
||||||
href={buildNeshanUrl(activeProject)}
|
href={buildGoogleMapsUrl(activeProject)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={POPUP_SECONDARY_CLASS}
|
className={POPUP_SECONDARY_CLASS}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { MdOpenInNew } from 'react-icons/md'
|
import { MdOpenInNew } from 'react-icons/md'
|
||||||
import { boundsForProjects, buildNeshanUrl, type MapProject } from '../data/mapProjects'
|
import { buildGoogleMapsUrl, type MapProject } from '../data/mapProjects'
|
||||||
import { useLang } from '../i18n/lang'
|
import { useLang } from '../i18n/lang'
|
||||||
import { MapViewport } from './MapViewport'
|
import { MapViewport } from './MapViewport'
|
||||||
import { POPUP_PRIMARY_CLASS, ProjectPopupCard } from './ProjectPopupCard'
|
import { POPUP_PRIMARY_CLASS, ProjectPopupCard } from './ProjectPopupCard'
|
||||||
@@ -25,11 +25,11 @@ export type ProjectsMapSingleProps = {
|
|||||||
export function ProjectsMapSingle({ location, images = [], className = '' }: ProjectsMapSingleProps) {
|
export function ProjectsMapSingle({ location, images = [], className = '' }: ProjectsMapSingleProps) {
|
||||||
const { t } = useLang()
|
const { t } = useLang()
|
||||||
const projects = useMemo(() => [location], [location])
|
const projects = useMemo(() => [location], [location])
|
||||||
const bounds = useMemo(() => boundsForProjects(projects, 0.008), [projects])
|
// بدون maxBounds — زوماوت آزاد تا دید کشوری (همرفتار با نقشهٔ صفحهٔ پروژهها)
|
||||||
const { containerRef, map, status, retry } = useMapLibre({
|
const { containerRef, map, status, retry } = useMapLibre({
|
||||||
center: [location.lng, location.lat],
|
center: [location.lng, location.lat],
|
||||||
zoom: 15,
|
zoom: 15,
|
||||||
maxBounds: bounds,
|
minZoom: 5,
|
||||||
})
|
})
|
||||||
const { popupHost, activeProject, closePopup } = useProjectMarkers(map, projects)
|
const { popupHost, activeProject, closePopup } = useProjectMarkers(map, projects)
|
||||||
|
|
||||||
@@ -45,10 +45,10 @@ export function ProjectsMapSingle({ location, images = [], className = '' }: Pro
|
|||||||
{popupHost &&
|
{popupHost &&
|
||||||
activeProject &&
|
activeProject &&
|
||||||
createPortal(
|
createPortal(
|
||||||
<ProjectPopupCard project={activeProject} images={images} onClose={closePopup}>
|
<ProjectPopupCard project={activeProject} images={images} hideImageWhenEmpty onClose={closePopup}>
|
||||||
{/* منطق دکمهٔ آدرس دستنخورده است — تنها اکشن این کارت */}
|
{/* منطق دکمهٔ آدرس دستنخورده است — تنها اکشن این کارت */}
|
||||||
<a
|
<a
|
||||||
href={buildNeshanUrl(activeProject)}
|
href={buildGoogleMapsUrl(activeProject)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={POPUP_PRIMARY_CLASS}
|
className={POPUP_PRIMARY_CLASS}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ type UseMapLibreOptions = {
|
|||||||
scrollZoom?: boolean
|
scrollZoom?: boolean
|
||||||
/** محدودهٔ مجاز جابهجایی نقشه — جلوگیری از رفتن به بیرون محدودهٔ پروژهها */
|
/** محدودهٔ مجاز جابهجایی نقشه — جلوگیری از رفتن به بیرون محدودهٔ پروژهها */
|
||||||
maxBounds?: maplibregl.LngLatBoundsLike
|
maxBounds?: maplibregl.LngLatBoundsLike
|
||||||
|
/** کف بزرگنمایی — زوماوت بیمعنا روی نقشهٔ خالی را میبندد */
|
||||||
|
minZoom?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** سقف انتظار سبک نقشه — سرور کاشی بیپاسخ یعنی خطای قابل تلاش دوباره، نه اسپینر بیپایان */
|
/** سقف انتظار سبک نقشه — سرور کاشی بیپاسخ یعنی خطای قابل تلاش دوباره، نه اسپینر بیپایان */
|
||||||
@@ -36,7 +38,7 @@ const ABSOLUTE_MAX_ZOOM = 22
|
|||||||
/** سقف جایگزین وقتی maxzoom منبع در دسترس نیست — برابر آرشیو فعلی (z14 + ۳) */
|
/** سقف جایگزین وقتی maxzoom منبع در دسترس نیست — برابر آرشیو فعلی (z14 + ۳) */
|
||||||
const FALLBACK_MAX_ZOOM = 17
|
const FALLBACK_MAX_ZOOM = 17
|
||||||
|
|
||||||
export function useMapLibre({ center, zoom, scrollZoom = true, maxBounds }: UseMapLibreOptions) {
|
export function useMapLibre({ center, zoom, scrollZoom = true, maxBounds, minZoom }: UseMapLibreOptions) {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||||
const [map, setMap] = useState<maplibregl.Map | null>(null)
|
const [map, setMap] = useState<maplibregl.Map | null>(null)
|
||||||
const [status, setStatus] = useState<MapStatus>('loading')
|
const [status, setStatus] = useState<MapStatus>('loading')
|
||||||
@@ -48,9 +50,17 @@ export function useMapLibre({ center, zoom, scrollZoom = true, maxBounds }: UseM
|
|||||||
|
|
||||||
let disposed = false
|
let disposed = false
|
||||||
let instance: maplibregl.Map | null = null
|
let instance: maplibregl.Map | null = null
|
||||||
|
const cleanups: Array<() => void> = []
|
||||||
let readyFallback: number | undefined
|
let readyFallback: number | undefined
|
||||||
let dprQuery: MediaQueryList | null = null
|
let dprQuery: MediaQueryList | null = null
|
||||||
let handleDprChange: (() => void) | null = null
|
let handleDprChange: (() => void) | null = null
|
||||||
|
// آیا میتوان کانتکست آنتیآلیاسه گرفت؟ (تست سبک، یکبار در هر ساخت نقشه)
|
||||||
|
const probeCanvas = document.createElement('canvas')
|
||||||
|
const canAntialias = Boolean(
|
||||||
|
probeCanvas.getContext('webgl2', { antialias: true }) ||
|
||||||
|
probeCanvas.getContext('webgl', { antialias: true }),
|
||||||
|
)
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timeoutId = window.setTimeout(() => controller.abort(), STYLE_TIMEOUT_MS)
|
const timeoutId = window.setTimeout(() => controller.abort(), STYLE_TIMEOUT_MS)
|
||||||
|
|
||||||
@@ -59,6 +69,16 @@ export function useMapLibre({ center, zoom, scrollZoom = true, maxBounds }: UseM
|
|||||||
|
|
||||||
// وبورکر نقشه و پروتکل pmtiles باید پیش از ساخت نقشه آماده باشند؛
|
// وبورکر نقشه و پروتکل pmtiles باید پیش از ساخت نقشه آماده باشند؛
|
||||||
// وگرنه نخستین درخواستهای کاشی در وبورکر بیپاسخ میمانند
|
// وگرنه نخستین درخواستهای کاشی در وبورکر بیپاسخ میمانند
|
||||||
|
// پیشسنجی WebGL: برخی مرورگرهای موبایل (یا حالت ذخیرهٔ باتری/داده) کانتکست
|
||||||
|
// WebGL نمیدهند. بدون این بررسی، سازندهٔ مپلیبر خطا میدهد و صفحه در حالت
|
||||||
|
// «در حال بارگذاری» گیر میکند — کاربر فقط یک قاب خالی میبیند.
|
||||||
|
const probe = document.createElement('canvas')
|
||||||
|
const gl = probe.getContext('webgl2') || probe.getContext('webgl') || probe.getContext('experimental-webgl')
|
||||||
|
if (!gl) {
|
||||||
|
setStatus('error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
Promise.all([loadMapStyle(controller.signal), ensureMapWorker()])
|
Promise.all([loadMapStyle(controller.signal), ensureMapWorker()])
|
||||||
.then(([style]) => {
|
.then(([style]) => {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
@@ -68,11 +88,14 @@ export function useMapLibre({ center, zoom, scrollZoom = true, maxBounds }: UseM
|
|||||||
center,
|
center,
|
||||||
zoom,
|
zoom,
|
||||||
maxBounds,
|
maxBounds,
|
||||||
|
minZoom,
|
||||||
// سقف اولیه تا رسیدن maxzoom واقعی منبع؛ سپس applyZoomLimit دقیق میکند
|
// سقف اولیه تا رسیدن maxzoom واقعی منبع؛ سپس applyZoomLimit دقیق میکند
|
||||||
maxZoom: FALLBACK_MAX_ZOOM,
|
maxZoom: FALLBACK_MAX_ZOOM,
|
||||||
renderWorldCopies: false,
|
renderWorldCopies: false,
|
||||||
// لبههای نرمتر خطوط و چندضلعیها — حس لوکس رندر برداری
|
// لبههای نرمتر خطوط و چندضلعیها — حس لوکس رندر برداری
|
||||||
canvasContextAttributes: { antialias: true },
|
// antialias فقط جایی که مطمئنیم پشتیبانی میشود — درخواست بیقید آن
|
||||||
|
// روی بعضی GPUهای موبایل کانتکست را رد میکند و نقشه ساخته نمیشود.
|
||||||
|
canvasContextAttributes: { antialias: canAntialias },
|
||||||
// کیفیت حداکثری در بزرگنماییهای عمیق: هیچ سطحی با مقیاسکردن سطح
|
// کیفیت حداکثری در بزرگنماییهای عمیق: هیچ سطحی با مقیاسکردن سطح
|
||||||
// بالاتر رندر نمیشود و همهی سطحها تا maxZoom از تقسیم هندسه
|
// بالاتر رندر نمیشود و همهی سطحها تا maxZoom از تقسیم هندسه
|
||||||
// میآیند (پیشفرض ۴ سطح آخر را overscale میکند). مبادله: کار
|
// میآیند (پیشفرض ۴ سطح آخر را overscale میکند). مبادله: کار
|
||||||
@@ -106,9 +129,26 @@ export function useMapLibre({ center, zoom, scrollZoom = true, maxBounds }: UseM
|
|||||||
instance.on('style.load', () => {
|
instance.on('style.load', () => {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
setStatus('ready')
|
setStatus('ready')
|
||||||
|
// یک همگامسازی پس از چیدمان نهایی (فونت/تصویر ممکن است ارتفاع را عوض کند)
|
||||||
window.setTimeout(() => instance?.resize(), 150)
|
window.setTimeout(() => instance?.resize(), 150)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* همگامسازی مداوم بوم با اندازهٔ واقعی ظرف.
|
||||||
|
*
|
||||||
|
* تایمرهای ثابت کافی نیستند: در حالت موبایل DevTools و روی گوشی واقعی،
|
||||||
|
* ظرف پس از ساختهشدن نقشه تغییر اندازه میدهد (اعمال viewport شبیهسازیشده،
|
||||||
|
* جمع/باز شدن نوار آدرس، چرخش صفحه). در آن حالت بوم با اندازهٔ قدیمی
|
||||||
|
* میماند و نقشه ناقص/تاریخشده دیده میشود. ResizeObserver دقیقاً همان
|
||||||
|
* لحظه خبر میدهد و resize روی همان فریم اعمال میشود.
|
||||||
|
*/
|
||||||
|
const sizeObserver = new ResizeObserver(() => {
|
||||||
|
if (disposed || !instance) return
|
||||||
|
instance.resize()
|
||||||
|
})
|
||||||
|
sizeObserver.observe(container)
|
||||||
|
cleanups.push(() => sizeObserver.disconnect())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* کنترل بزرگنمایی همراه خود نقشه ساخته میشود و با map.remove() پایین
|
* کنترل بزرگنمایی همراه خود نقشه ساخته میشود و با map.remove() پایین
|
||||||
* میآید — map.remove بهتنهایی onRemove همهی کنترلها را صدا میزند،
|
* میآید — map.remove بهتنهایی onRemove همهی کنترلها را صدا میزند،
|
||||||
@@ -170,7 +210,9 @@ export function useMapLibre({ center, zoom, scrollZoom = true, maxBounds }: UseM
|
|||||||
|
|
||||||
setMap(instance)
|
setMap(instance)
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((error) => {
|
||||||
|
// خطای ساخت/بارگذاری نقشه — وضعیت خطا با پیام قابلتلاشدوباره، نه قاب خالی
|
||||||
|
console.warn('[map] راهاندازی نقشه ناموفق بود:', error)
|
||||||
if (!disposed) setStatus('error')
|
if (!disposed) setStatus('error')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -179,6 +221,7 @@ export function useMapLibre({ center, zoom, scrollZoom = true, maxBounds }: UseM
|
|||||||
window.clearTimeout(timeoutId)
|
window.clearTimeout(timeoutId)
|
||||||
if (readyFallback !== undefined) window.clearTimeout(readyFallback)
|
if (readyFallback !== undefined) window.clearTimeout(readyFallback)
|
||||||
if (dprQuery && handleDprChange) dprQuery.removeEventListener('change', handleDprChange)
|
if (dprQuery && handleDprChange) dprQuery.removeEventListener('change', handleDprChange)
|
||||||
|
cleanups.forEach((fn) => fn())
|
||||||
controller.abort()
|
controller.abort()
|
||||||
instance?.remove()
|
instance?.remove()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ export function About() {
|
|||||||
<>
|
<>
|
||||||
<PageHero pageKey="about" />
|
<PageHero pageKey="about" />
|
||||||
|
|
||||||
<Story showLink={false} showLabel={false} showQuote={true} alignTop={true} />
|
<Story showLink={false} showLabel={false} showQuote={true} alignTop={true} tone="black" />
|
||||||
|
|
||||||
<Stats />
|
<Stats tone="gray" />
|
||||||
<Timeline />
|
<Timeline tone="black" />
|
||||||
<WhyUs />
|
<WhyUs tone="gray" />
|
||||||
<Team />
|
<Team tone="black" />
|
||||||
<ContactSection />
|
<ContactSection />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Reveal } from '../components/Reveal';
|
|||||||
import { ButtonLink } from '../components/Button';
|
import { ButtonLink } from '../components/Button';
|
||||||
import { ArticleCard } from '../components/ArticleCard';
|
import { ArticleCard } from '../components/ArticleCard';
|
||||||
import { ArticleMeta } from '../components/ArticleMeta';
|
import { ArticleMeta } from '../components/ArticleMeta';
|
||||||
|
import { ArticleFaq } from '../components/ArticleFaq';
|
||||||
import { TableOfContents, extractToc, type TocItem } from '../components/TableOfContents';
|
import { TableOfContents, extractToc, type TocItem } from '../components/TableOfContents';
|
||||||
import { Newsletter } from '../components/home/Newsletter';
|
import { Newsletter } from '../components/home/Newsletter';
|
||||||
import { useSite } from '../api';
|
import { useSite } from '../api';
|
||||||
@@ -13,9 +14,10 @@ import { useLang } from '../i18n/lang';
|
|||||||
import { applySeo } from '../seo';
|
import { applySeo } from '../seo';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor';
|
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor';
|
||||||
import { docToText } from '../utils/tiptapDoc';
|
import { docToText, docHasText } from '../utils/tiptapDoc';
|
||||||
import { detectDirection } from '../utils/direction';
|
import { detectDirection } from '../utils/direction';
|
||||||
import { wrapScriptRuns } from '../utils/bidiText';
|
import { wrapScriptRuns } from '../utils/bidiText';
|
||||||
|
import { toPublishedISO } from '../utils/date';
|
||||||
import type { Article } from '../data/site';
|
import type { Article } from '../data/site';
|
||||||
|
|
||||||
/** بدنه مقاله را به HTML و فهرست مطالب تبدیل میکند — سند TipTap جدید یا پاراگرافهای قدیمی */
|
/** بدنه مقاله را به HTML و فهرست مطالب تبدیل میکند — سند TipTap جدید یا پاراگرافهای قدیمی */
|
||||||
@@ -44,28 +46,60 @@ export function ArticleDetail() {
|
|||||||
const { t, href, lang } = useLang();
|
const { t, href, lang } = useLang();
|
||||||
const article = articles.find((a) => a.slug === slug);
|
const article = articles.find((a) => a.slug === slug);
|
||||||
|
|
||||||
// عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله
|
// عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله — همارز نسخهٔ سمت سرور (seo_pages.py)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!article) return;
|
if (!article) return;
|
||||||
|
const pageUrl = new URL(href(`/blog/${article.slug}`), window.location.origin).href;
|
||||||
|
const absolute = (path: string) => new URL(path, window.location.origin).href;
|
||||||
|
const authorName = (article.author ?? '').trim();
|
||||||
|
const description = article.meta_description || article.excerpt;
|
||||||
|
const articleSchema: Record<string, unknown> = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Article',
|
||||||
|
headline: article.title,
|
||||||
|
description,
|
||||||
|
image: article.image ? absolute(article.image) : undefined,
|
||||||
|
inLanguage: lang,
|
||||||
|
// نویسندهٔ انسانی ثبتشده → Person (سیگنال E-E-A-T)؛ وگرنه برند
|
||||||
|
author: authorName
|
||||||
|
? { '@type': 'Person', name: authorName }
|
||||||
|
: { '@type': 'Organization', name: brand.name },
|
||||||
|
publisher: { '@type': 'Organization', name: brand.name },
|
||||||
|
mainEntityOfPage: pageUrl,
|
||||||
|
};
|
||||||
|
const published = toPublishedISO(article.date);
|
||||||
|
if (published) articleSchema.datePublished = published;
|
||||||
|
const breadcrumbSchema = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'BreadcrumbList',
|
||||||
|
itemListElement: [
|
||||||
|
{ '@type': 'ListItem', position: 1, name: t('home'), item: absolute(href('/')) },
|
||||||
|
{ '@type': 'ListItem', position: 2, name: t('blog'), item: absolute(href('/blog')) },
|
||||||
|
{ '@type': 'ListItem', position: 3, name: article.title, item: pageUrl },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
// پرسشوپاسخهای پایان مقاله → FAQPage (برای موتورهای هوش مصنوعی)
|
||||||
|
const faqEntities = (article.faqs ?? [])
|
||||||
|
.filter((f) => (f.question ?? '').trim() && (f.answer ?? '').trim())
|
||||||
|
.map((f) => ({
|
||||||
|
'@type': 'Question',
|
||||||
|
name: f.question.trim(),
|
||||||
|
acceptedAnswer: { '@type': 'Answer', text: f.answer.trim() },
|
||||||
|
}));
|
||||||
|
const faqSchema = faqEntities.length
|
||||||
|
? { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faqEntities }
|
||||||
|
: null;
|
||||||
applySeo({
|
applySeo({
|
||||||
title: `${article.title} | ${brand.name}`,
|
title: `${article.title} | ${brand.name}`,
|
||||||
// توضیحات سئوی اختصاصی در اولویت است؛ در نبود آن، چکیده
|
// توضیحات سئوی اختصاصی در اولویت است؛ در نبود آن، چکیده
|
||||||
description: article.meta_description || article.excerpt,
|
description,
|
||||||
path: href(`/blog/${article.slug}`),
|
path: href(`/blog/${article.slug}`),
|
||||||
image: article.image,
|
image: article.image,
|
||||||
type: 'article',
|
type: 'article',
|
||||||
lang,
|
lang,
|
||||||
jsonLd: {
|
// alternate انگلیسی فقط با وجود ترجمهٔ واقعی
|
||||||
'@context': 'https://schema.org',
|
hasEn: !!(article.title_en?.trim() || article.excerpt_en?.trim() || docHasText(article.body_en)),
|
||||||
'@type': 'Article',
|
jsonLd: [articleSchema, breadcrumbSchema, ...(faqSchema ? [faqSchema] : [])],
|
||||||
headline: article.title,
|
|
||||||
description: article.meta_description || article.excerpt,
|
|
||||||
image: article.image ? new URL(article.image, window.location.origin).href : undefined,
|
|
||||||
inLanguage: lang,
|
|
||||||
author: { '@type': 'Organization', name: article.author?.trim() || brand.name },
|
|
||||||
publisher: { '@type': 'Organization', name: brand },
|
|
||||||
mainEntityOfPage: new URL(href(`/blog/${article.slug}`), window.location.origin).href,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}, [article?.id, lang, href]);
|
}, [article?.id, lang, href]);
|
||||||
|
|
||||||
@@ -104,6 +138,8 @@ export function ArticleDetail() {
|
|||||||
alt={article.image_alt || article.title}
|
alt={article.image_alt || article.title}
|
||||||
title={article.image_title || undefined}
|
title={article.image_title || undefined}
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
|
fetchPriority="high"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-ink/75" aria-hidden="true" />
|
<div className="absolute inset-0 bg-ink/75" aria-hidden="true" />
|
||||||
<div
|
<div
|
||||||
@@ -130,7 +166,7 @@ export function ArticleDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<article className="bg-ink py-20 lg:py-28">
|
<article className="bg-char py-20 lg:py-28">
|
||||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||||
<div
|
<div
|
||||||
dir={dir}
|
dir={dir}
|
||||||
@@ -156,6 +192,13 @@ export function ArticleDetail() {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* پرسشوپاسخهای پایان مقاله — دوزبانه (ستون فعال، با برگشت به فارسی) */}
|
||||||
|
{(article.faqs ?? []).length > 0 && (
|
||||||
|
<Reveal delay={0.05}>
|
||||||
|
<ArticleFaq faqs={article.faqs ?? []} />
|
||||||
|
</Reveal>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* فهرست مطالب — در دسکتاپ کنار متن میچسبد و همراه مقاله پایین میآید */}
|
{/* فهرست مطالب — در دسکتاپ کنار متن میچسبد و همراه مقاله پایین میآید */}
|
||||||
@@ -164,14 +207,14 @@ export function ArticleDetail() {
|
|||||||
dir={dir}
|
dir={dir}
|
||||||
className={`order-first lg:order-none lg:row-start-1 ${tocColumns}`}
|
className={`order-first lg:order-none lg:row-start-1 ${tocColumns}`}
|
||||||
>
|
>
|
||||||
<TableOfContents items={toc} className="lg:sticky lg:top-28" />
|
<TableOfContents items={toc} title={t('tableOfContents')} className="lg:sticky lg:top-28" />
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<section className="bg-char py-20 lg:py-28" aria-label={t('relatedArticlesAria')}>
|
<section className="bg-ink py-20 lg:py-28" aria-label={t('relatedArticlesAria')}>
|
||||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||||
<h2 className="text-section font-bold text-ivory">{t('relatedArticles')}</h2>
|
<h2 className="text-section font-bold text-ivory">{t('relatedArticles')}</h2>
|
||||||
<div className="mt-6 h-px w-24 bg-copper/70" />
|
<div className="mt-6 h-px w-24 bg-copper/70" />
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useSite } from '../api'
|
|||||||
import { useLang } from '../i18n/lang'
|
import { useLang } from '../i18n/lang'
|
||||||
import { EditableItem } from '../admin/Editable'
|
import { EditableItem } from '../admin/Editable'
|
||||||
import { useAdmin } from '../admin/AdminContext'
|
import { useAdmin } from '../admin/AdminContext'
|
||||||
import { formatJalali } from '../utils/date'
|
import { formatJalali, formatEnDate } from '../utils/date'
|
||||||
import { toPersianDigits } from '../utils/digits'
|
import { toPersianDigits } from '../utils/digits'
|
||||||
import { PlusIcon } from 'lucide-react'
|
import { PlusIcon } from 'lucide-react'
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ function DraftBadge({ className = '' }: { className?: string }) {
|
|||||||
|
|
||||||
export function Blog() {
|
export function Blog() {
|
||||||
const { articles } = useSite()
|
const { articles } = useSite()
|
||||||
const { t, href } = useLang()
|
const { t, href, lang } = useLang()
|
||||||
const { isEditMode } = useAdmin()
|
const { isEditMode } = useAdmin()
|
||||||
|
|
||||||
// دکمه «نوشتن مقاله جدید» فقط در حالت ویرایش ادمین دیده میشود
|
// دکمه «نوشتن مقاله جدید» فقط در حالت ویرایش ادمین دیده میشود
|
||||||
@@ -46,7 +46,7 @@ export function Blog() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHero pageKey="blog" />
|
<PageHero pageKey="blog" />
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-label={t('articlesAria')}>
|
<section className="bg-char py-24 lg:py-32" aria-label={t('articlesAria')}>
|
||||||
<div className="mx-auto max-w-site px-6 text-center lg:px-10">
|
<div className="mx-auto max-w-site px-6 text-center lg:px-10">
|
||||||
<h2 className="text-3xl font-bold text-ivory">{t('blogEmpty')}</h2>
|
<h2 className="text-3xl font-bold text-ivory">{t('blogEmpty')}</h2>
|
||||||
<p className="mt-4 text-sm font-light text-ivory/60">
|
<p className="mt-4 text-sm font-light text-ivory/60">
|
||||||
@@ -66,7 +66,7 @@ export function Blog() {
|
|||||||
<>
|
<>
|
||||||
<PageHero pageKey="blog" />
|
<PageHero pageKey="blog" />
|
||||||
|
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-label={t('articlesAria')}>
|
<section className="bg-char py-24 lg:py-32" aria-label={t('articlesAria')}>
|
||||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||||
<div className="mb-10 flex justify-start">{writeButton}</div>
|
<div className="mb-10 flex justify-start">{writeButton}</div>
|
||||||
<Reveal>
|
<Reveal>
|
||||||
@@ -80,7 +80,7 @@ export function Blog() {
|
|||||||
<img
|
<img
|
||||||
src={featured.image}
|
src={featured.image}
|
||||||
alt={`${t('articleImagePrefix')} ${featured.title}`}
|
alt={`${t('articleImagePrefix')} ${featured.title}`}
|
||||||
className="h-full min-h-[20rem] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
|
className="h-full min-h-[20rem] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105" fetchPriority="high" decoding="async"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col justify-center p-8 lg:p-12">
|
<div className="flex flex-col justify-center p-8 lg:p-12">
|
||||||
@@ -96,10 +96,10 @@ export function Blog() {
|
|||||||
<p className="mt-6 text-sm font-light leading-10 text-ivory/65">{featured.excerpt}</p>
|
<p className="mt-6 text-sm font-light leading-10 text-ivory/65">{featured.excerpt}</p>
|
||||||
<div className="mt-10 flex items-center justify-between border-t border-ivory/10 pt-8 text-xs font-light text-ivory/50">
|
<div className="mt-10 flex items-center justify-between border-t border-ivory/10 pt-8 text-xs font-light text-ivory/50">
|
||||||
<span className="flex items-center gap-4">
|
<span className="flex items-center gap-4">
|
||||||
<span>{formatJalali(featured.date)}</span>
|
<span>{lang === 'en' ? formatEnDate(featured.date) : formatJalali(featured.date)}</span>
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<ClockIcon className="h-3.5 w-3.5" />
|
<ClockIcon className="h-3.5 w-3.5" />
|
||||||
{toPersianDigits(featured.read)}
|
{lang === 'en' ? featured.read : toPersianDigits(featured.read)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-2 text-copper">
|
<span className="flex items-center gap-2 text-copper">
|
||||||
|
|||||||
@@ -1,18 +1,43 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { PageHero } from '../components/PageHero'
|
import { PageHero } from '../components/PageHero'
|
||||||
import { ContactSection } from '../components/ContactSection'
|
import { ContactSection } from '../components/ContactSection'
|
||||||
|
import { usePageHero } from '../api'
|
||||||
import { useLang } from '../i18n/lang'
|
import { useLang } from '../i18n/lang'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* صفحهٔ تماس — یک تصویر پیوسته برای کل صفحه: عنوان هیرو و فرم/اطلاعات
|
||||||
|
* هر دو روی همان پسزمینه مینشینند تا تصویر دو بار تکرار نشود.
|
||||||
|
*/
|
||||||
export function ContactPage() {
|
export function ContactPage() {
|
||||||
const { t } = useLang()
|
const { t } = useLang()
|
||||||
|
const hero = usePageHero('contact')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="relative" aria-label={t('officesAria')}>
|
||||||
<PageHero pageKey="contact" />
|
{hero.image && (
|
||||||
|
<img
|
||||||
|
src={hero.image}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
|
fetchPriority="high"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* فقط گرادیان خوانایی — بدون پردهٔ خاکستری روی تصویر */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(to top, #27221c 2%, rgba(39,34,28,0.55) 45%, rgba(39,34,28,0.35) 100%)',
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
<section className="bg-ink py-2" aria-label={t('officesAria')} />
|
<div className="relative">
|
||||||
|
<PageHero pageKey="contact" hideImage />
|
||||||
<ContactSection />
|
<ContactSection bare />
|
||||||
</>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export function FaqPage() {
|
|||||||
<>
|
<>
|
||||||
<PageHero pageKey="faq" />
|
<PageHero pageKey="faq" />
|
||||||
|
|
||||||
<Faq hideHeading={true} />
|
<Faq hideHeading={true} tone="black" />
|
||||||
|
|
||||||
<ContactSection />
|
<ContactSection />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ import { Stats } from '../components/home/Stats';
|
|||||||
import { Services } from '../components/home/Services';
|
import { Services } from '../components/home/Services';
|
||||||
import { BeforeAfter } from '../components/home/BeforeAfter';
|
import { BeforeAfter } from '../components/home/BeforeAfter';
|
||||||
import { FeaturedProjects } from '../components/home/FeaturedProjects';
|
import { FeaturedProjects } from '../components/home/FeaturedProjects';
|
||||||
import { Gallery } from '../components/home/Gallery';
|
|
||||||
import { Timeline } from '../components/home/Timeline';
|
import { Timeline } from '../components/home/Timeline';
|
||||||
import { WhyUs } from '../components/home/WhyUs';
|
import { WhyUs } from '../components/home/WhyUs';
|
||||||
import { Testimonials } from '../components/home/Testimonials';
|
|
||||||
import { Team } from '../components/home/Team';
|
import { Team } from '../components/home/Team';
|
||||||
import { Articles } from '../components/home/Articles';
|
import { Articles } from '../components/home/Articles';
|
||||||
import { Faq } from '../components/home/Faq';
|
import { Faq } from '../components/home/Faq';
|
||||||
@@ -20,14 +18,12 @@ export function Home() {
|
|||||||
<>
|
<>
|
||||||
<Hero />
|
<Hero />
|
||||||
<Stats />
|
<Stats />
|
||||||
<Story />
|
<Story showQuote={true} />
|
||||||
<Services />
|
<Services />
|
||||||
<BeforeAfter />
|
<BeforeAfter />
|
||||||
<FeaturedProjects />
|
<FeaturedProjects />
|
||||||
<Gallery />
|
|
||||||
<Timeline />
|
<Timeline />
|
||||||
<WhyUs />
|
<WhyUs />
|
||||||
<Testimonials />
|
|
||||||
<Team />
|
<Team />
|
||||||
<Articles />
|
<Articles />
|
||||||
<Faq />
|
<Faq />
|
||||||
|
|||||||
@@ -5,42 +5,38 @@ import { SectionHeading } from '../components/SectionHeading'
|
|||||||
import { Stats } from '../components/home/Stats'
|
import { Stats } from '../components/home/Stats'
|
||||||
import { ContactSection } from '../components/ContactSection'
|
import { ContactSection } from '../components/ContactSection'
|
||||||
import { useSectionHeading, useSite } from '../api'
|
import { useSectionHeading, useSite } from '../api'
|
||||||
import { useLang, formatNumber } from '../i18n/lang'
|
|
||||||
import { AddItemButton, EditableItem } from '../admin/Editable'
|
import { AddItemButton, EditableItem } from '../admin/Editable'
|
||||||
|
|
||||||
export function Investment() {
|
export function Investment() {
|
||||||
const { investment_models } = useSite()
|
const { investment_models } = useSite()
|
||||||
const { lang } = useLang()
|
|
||||||
const heading = useSectionHeading('investment_models')
|
const heading = useSectionHeading('investment_models')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHero pageKey="investment" />
|
<PageHero pageKey="investment" />
|
||||||
|
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="models-title">
|
<section className="bg-char py-24 lg:py-32" aria-labelledby="models-title">
|
||||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||||
<SectionHeading title={heading.title} lead={heading.lead} />
|
<SectionHeading title={heading.title} lead={heading.lead} align="center" />
|
||||||
<div className="mt-14 grid gap-6 lg:grid-cols-3">
|
<div className="mt-14 grid gap-6 lg:grid-cols-3">
|
||||||
{investment_models.map((model, i) => (
|
{investment_models.map((model, i) => (
|
||||||
<Reveal key={model.id} delay={0.06 * i} className="h-full">
|
<Reveal key={model.id} delay={0.06 * i} className="h-full">
|
||||||
<EditableItem resource="investment_models" item={model} className="h-full">
|
<EditableItem resource="investment_models" item={model} className="h-full">
|
||||||
<article className="group relative flex h-full flex-col overflow-hidden rounded-2xl border border-ivory/10 bg-char/40 p-10 backdrop-blur-md transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:bg-char/70 hover:shadow-[0_12px_40px_-10px_rgba(197,143,92,0.35)] transform-gpu">
|
<article className="group relative flex h-full flex-col overflow-hidden rounded-2xl border border-ivory/10 bg-char/40 p-10 text-center backdrop-blur-md transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:bg-char/70 hover:shadow-[0_12px_40px_-10px_rgba(197,143,92,0.35)] transform-gpu">
|
||||||
{/* لایه نوری ظریف که در زمان هاور روشن میشود */}
|
{/* لایه نوری ظریف که در زمان هاور روشن میشود */}
|
||||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-white/[0.03] to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
|
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-white/[0.03] to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
|
||||||
|
|
||||||
<p className="relative z-10 text-sm font-bold text-copper/80">{lang === 'fa' ? `۰${formatNumber(i + 1, lang)}` : formatNumber(i + 1, lang).padStart(2, '0')}</p>
|
<h3 className="relative z-10 text-xl font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
|
||||||
|
|
||||||
<h3 className="relative z-10 mt-6 text-2xl font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
|
|
||||||
{model.title}
|
{model.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<p className="relative z-10 mt-5 text-sm font-light leading-9 text-ivory/65">{model.desc}</p>
|
<p className="relative z-10 mt-4 text-base font-light leading-9 text-ivory/60">{model.desc}</p>
|
||||||
|
|
||||||
<div className="relative z-10 mt-auto pt-10">
|
<div className="relative z-10 mt-auto pt-10">
|
||||||
<ul className="space-y-4 border-t border-ivory/10 pt-8 transition-colors duration-500 group-hover:border-copper/20">
|
<ul className="space-y-4 border-t border-ivory/10 pt-8 transition-colors duration-500 group-hover:border-copper/20">
|
||||||
{model.points.map((point) => (
|
{model.points.map((point) => (
|
||||||
<li key={point} className="flex items-start gap-3 text-sm font-light leading-8 text-ivory/70 transition-colors duration-300 group-hover:text-ivory">
|
<li key={point} className="flex items-center justify-center gap-3 text-sm font-light leading-8 text-ivory/70 transition-colors duration-300 group-hover:text-ivory">
|
||||||
<span className="mt-3.5 h-1.5 w-1.5 shrink-0 rounded-full bg-copper/60 transition-all duration-300 ease-lux group-hover:scale-125 group-hover:bg-copper group-hover:shadow-[0_0_10px_rgba(197,143,92,0.8)]" aria-hidden="true" />
|
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-copper/60 transition-all duration-300 ease-lux group-hover:scale-125 group-hover:bg-copper group-hover:shadow-[0_0_10px_rgba(197,143,92,0.8)]" aria-hidden="true" />
|
||||||
{point}
|
{point}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -55,7 +51,7 @@ export function Investment() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Stats />
|
<Stats tone="gray" />
|
||||||
<ContactSection />
|
<ContactSection />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,25 +9,57 @@ import { Lightbox } from '../components/Lightbox'
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useSite, useStatusLabel } from '../api';
|
import { useSite, useStatusLabel } from '../api';
|
||||||
import { useLang } from '../i18n/lang';
|
import { useLang } from '../i18n/lang';
|
||||||
|
import { useAdmin } from '../admin/AdminContext';
|
||||||
|
import { LazyProjectsMapSingle } from '../map/LazyMaps';
|
||||||
|
import { createPlaceholderProject, findMapProjectByName } from '../data/mapProjects';
|
||||||
import { applySeo } from '../seo';
|
import { applySeo } from '../seo';
|
||||||
|
|
||||||
export function ProjectDetail() {
|
export function ProjectDetail() {
|
||||||
const { slug } = useParams<{ slug: string }>()
|
const { slug } = useParams<{ slug: string }>()
|
||||||
const { projects, brand } = useSite()
|
const { projects, brand } = useSite()
|
||||||
const { t, href, lang } = useLang()
|
const { t, href, lang } = useLang()
|
||||||
|
const { canManageSite } = useAdmin()
|
||||||
const project = projects.find((p) => p.slug === slug)
|
const project = projects.find((p) => p.slug === slug)
|
||||||
const statusLabel = useStatusLabel(project?.status ?? '')
|
const statusLabel = useStatusLabel(project?.status ?? '')
|
||||||
const [lightbox, setLightbox] = useState<number | null>(null)
|
const [lightbox, setLightbox] = useState<number | null>(null)
|
||||||
|
|
||||||
// عنوان تب، توضیح متا و OG از خود پروژه
|
// مختصات پروژه از فهرست سنجاقهای شیت؛ نبودش → محل جایگزین تا نقشه خالی نماند
|
||||||
|
const mapLocation = project
|
||||||
|
? (findMapProjectByName(project.title) ?? createPlaceholderProject(project.title, project.summary))
|
||||||
|
: null
|
||||||
|
const mapImages = project && Array.isArray(project.gallery) ? project.gallery.filter(Boolean) : []
|
||||||
|
|
||||||
|
// عنوان تب، توضیح متا، OG و JSON-LD از خود پروژه — همارز نسخهٔ سمت سرور (seo_pages.py)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!project) return;
|
if (!project) return;
|
||||||
|
const absolute = (path: string) => new URL(path, window.location.origin).href;
|
||||||
applySeo({
|
applySeo({
|
||||||
title: `${project.title} | ${brand.name}`,
|
title: `${project.title} | ${brand.name}`,
|
||||||
description: project.summary,
|
description: project.summary,
|
||||||
path: href(`/projects/${project.slug}`),
|
path: href(`/projects/${project.slug}`),
|
||||||
image: project.image,
|
image: project.image,
|
||||||
lang,
|
lang,
|
||||||
|
// alternate انگلیسی فقط با وجود ترجمهٔ واقعی
|
||||||
|
hasEn: !!(project.title_en?.trim() || project.summary_en?.trim()),
|
||||||
|
jsonLd: [
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'WebPage',
|
||||||
|
name: project.title,
|
||||||
|
description: project.summary,
|
||||||
|
image: project.image ? absolute(project.image) : undefined,
|
||||||
|
inLanguage: lang,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'BreadcrumbList',
|
||||||
|
itemListElement: [
|
||||||
|
{ '@type': 'ListItem', position: 1, name: t('home'), item: absolute(href('/')) },
|
||||||
|
{ '@type': 'ListItem', position: 2, name: t('projects'), item: absolute(href('/projects')) },
|
||||||
|
{ '@type': 'ListItem', position: 3, name: project.title, item: absolute(href(`/projects/${project.slug}`)) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}, [project?.id, lang, href]);
|
}, [project?.id, lang, href]);
|
||||||
|
|
||||||
@@ -48,6 +80,15 @@ export function ProjectDetail() {
|
|||||||
|
|
||||||
const related = projects.filter((p) => p.slug !== project.slug).slice(0, 3)
|
const related = projects.filter((p) => p.slug !== project.slug).slice(0, 3)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* پیمایش پروژهها — همان ترتیبی که فهرست پروژهها دارد (position از API).
|
||||||
|
* در ابتدا/انتهای فهرست، حلقه میزند تا کاوش هیچوقت قطع نشود.
|
||||||
|
*/
|
||||||
|
const currentIndex = projects.findIndex((p) => p.slug === project.slug)
|
||||||
|
const total = projects.length
|
||||||
|
const prevProject = total > 1 ? projects[(currentIndex + 1) % total] : undefined // قدیمیتر
|
||||||
|
const nextProject = total > 1 ? projects[(currentIndex - 1 + total) % total] : undefined // تازهتر
|
||||||
|
|
||||||
// مشخصات هر پروژه — همان فهرستی که تیم داده دارد:
|
// مشخصات هر پروژه — همان فهرستی که تیم داده دارد:
|
||||||
// وضعیت / سال / تعداد طبقات / واحدها / نوع پروژه / زیربنا / کاربری
|
// وضعیت / سال / تعداد طبقات / واحدها / نوع پروژه / زیربنا / کاربری
|
||||||
// ردیفهای خالی نمایش داده نمیشوند.
|
// ردیفهای خالی نمایش داده نمیشوند.
|
||||||
@@ -67,7 +108,7 @@ export function ProjectDetail() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="relative flex h-[62vh] min-h-[440px] items-end overflow-hidden">
|
<section className="relative flex h-[62vh] min-h-[440px] items-end overflow-hidden">
|
||||||
<img src={project.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />
|
<img src={project.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" fetchPriority="high" decoding="async" />
|
||||||
<div className="absolute inset-0 bg-ink/72" aria-hidden="true" />
|
<div className="absolute inset-0 bg-ink/72" aria-hidden="true" />
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0"
|
className="absolute inset-0"
|
||||||
@@ -88,19 +129,36 @@ export function ProjectDetail() {
|
|||||||
</nav>
|
</nav>
|
||||||
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{project.title}</h1>
|
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{project.title}</h1>
|
||||||
<p className="mt-5 max-w-2xl text-base font-light leading-9 text-ivory/70">{project.summary}</p>
|
<p className="mt-5 max-w-2xl text-base font-light leading-9 text-ivory/70">{project.summary}</p>
|
||||||
{/* ویرایش این پروژه — صفحهٔ اختصاصی ویرایش */}
|
{/* ویرایش این پروژه — فقط برای ادمین واردشده؛ هرگز برای بازدیدکننده رندر نشود */}
|
||||||
<div className="mt-5">
|
{canManageSite && (
|
||||||
<Link
|
<div className="mt-5">
|
||||||
to={`/admin/projects/${project.id}`}
|
<Link
|
||||||
data-admin-link
|
to={`/admin/projects/${project.id}`}
|
||||||
className="inline-flex items-center gap-2 rounded-full border border-copper/50 bg-ink/90 px-4 py-2 text-xs font-light text-copper backdrop-blur-sm transition-colors duration-200 hover:bg-copper hover:text-char"
|
data-admin-link
|
||||||
>
|
className="inline-flex items-center gap-2 rounded-full border border-copper/50 bg-ink/90 px-4 py-2 text-xs font-light text-copper backdrop-blur-sm transition-colors duration-200 hover:bg-copper hover:text-char"
|
||||||
ویرایش این پروژه (عنوان، مشخصات، گالری، متن)
|
>
|
||||||
</Link>
|
ویرایش این پروژه (عنوان، مشخصات، گالری، متن)
|
||||||
</div>
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* نقشهٔ مختصات پروژه — بلافاصله زیر هیرو */}
|
||||||
|
{mapLocation && (
|
||||||
|
<section className="bg-char py-10 lg:py-14" aria-label={t('mapAria')}>
|
||||||
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||||
|
<div className="w-full max-w-full overflow-hidden rounded-2xl border border-white/10">
|
||||||
|
<LazyProjectsMapSingle
|
||||||
|
location={mapLocation}
|
||||||
|
images={mapImages}
|
||||||
|
className="h-[320px] md:h-[420px] w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="bg-char py-20 lg:py-28" aria-label={t('projectDetailsAria')}>
|
<section className="bg-char py-20 lg:py-28" aria-label={t('projectDetailsAria')}>
|
||||||
<div className="mx-auto grid max-w-site items-start gap-12 px-6 lg:grid-cols-2 lg:gap-16 lg:px-10">
|
<div className="mx-auto grid max-w-site items-start gap-12 px-6 lg:grid-cols-2 lg:gap-16 lg:px-10">
|
||||||
|
|
||||||
@@ -143,6 +201,8 @@ export function ProjectDetail() {
|
|||||||
src={project.gallery[0] || project.image}
|
src={project.gallery[0] || project.image}
|
||||||
alt=""
|
alt=""
|
||||||
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
|
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<div className="mt-4 grid grid-cols-3 gap-4">
|
<div className="mt-4 grid grid-cols-3 gap-4">
|
||||||
@@ -157,6 +217,8 @@ export function ProjectDetail() {
|
|||||||
src={img}
|
src={img}
|
||||||
alt=""
|
alt=""
|
||||||
className="aspect-square w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
|
className="aspect-square w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -166,6 +228,60 @@ export function ProjectDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* پیمایش پروژهها — رفتن به پروژهٔ قبلی/بعدی بدون بازگشت به فهرست */}
|
||||||
|
{(prevProject || nextProject) && (
|
||||||
|
<nav
|
||||||
|
className="border-t border-ivory/10 bg-char py-10 lg:py-12"
|
||||||
|
aria-label={t('projectNavAria')}
|
||||||
|
>
|
||||||
|
<div className="mx-auto flex max-w-site items-stretch justify-between gap-3 px-6 lg:px-10">
|
||||||
|
{prevProject ? (
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-3 md:max-w-[46%]">
|
||||||
|
{/* فلش بیرون کادر، روی لبهٔ بیرونی */}
|
||||||
|
<ArrowLeftIcon
|
||||||
|
className="h-5 w-5 shrink-0 -scale-x-100 text-copper transition-transform duration-300 group-hover/prev:-translate-x-0.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
to={href(`/projects/${prevProject.slug}`)}
|
||||||
|
className="group/prev flex min-w-0 flex-1 items-center justify-end rounded-2xl border border-ivory/12 bg-ink/40 px-4 py-3.5 transition-all duration-300 ease-lux hover:-translate-y-0.5 hover:border-copper/50 hover:bg-ink/70 md:px-6 md:py-5"
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 flex-col text-end">
|
||||||
|
<span className="text-[11px] font-light text-ivory/50">{t('prevProject')}</span>
|
||||||
|
<span className="mt-1 truncate text-sm font-bold text-ivory transition-colors duration-300 group-hover/prev:text-copper md:text-base">
|
||||||
|
{prevProject.title}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="flex-1" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{nextProject && (
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-3 md:max-w-[46%]">
|
||||||
|
<Link
|
||||||
|
to={href(`/projects/${nextProject.slug}`)}
|
||||||
|
className="group/next flex min-w-0 flex-1 items-center justify-start rounded-2xl border border-ivory/12 bg-ink/40 px-4 py-3.5 transition-all duration-300 ease-lux hover:-translate-y-0.5 hover:border-copper/50 hover:bg-ink/70 md:px-6 md:py-5"
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 flex-col text-start">
|
||||||
|
<span className="text-[11px] font-light text-ivory/50">{t('nextProject')}</span>
|
||||||
|
<span className="mt-1 truncate text-sm font-bold text-ivory transition-colors duration-300 group-hover/next:text-copper md:text-base">
|
||||||
|
{nextProject.title}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
{/* فلش بیرون کادر، روی لبهٔ بیرونی */}
|
||||||
|
<ArrowLeftIcon
|
||||||
|
className="h-5 w-5 shrink-0 text-copper transition-transform duration-300 group-hover/next:translate-x-0.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="bg-ink py-20 lg:py-28" aria-label={t('relatedProjectsAria')}>
|
<section className="bg-ink py-20 lg:py-28" aria-label={t('relatedProjectsAria')}>
|
||||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||||
<h2 className="text-section font-bold text-ivory">{t('otherProjects')}</h2>
|
<h2 className="text-section font-bold text-ivory">{t('otherProjects')}</h2>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { PageHero } from '../components/PageHero';
|
import { PageHero } from '../components/PageHero';
|
||||||
import { Reveal } from '../components/Reveal';
|
import { Reveal } from '../components/Reveal';
|
||||||
import { ProjectCard } from '../components/ProjectCard';
|
import { ProjectCard } from '../components/ProjectCard';
|
||||||
import { PremiumButton } from '../components/PremiumButton';
|
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||||
import { Gallery } from '../components/home/Gallery';
|
import { toPersianDigits } from '../utils/digits';
|
||||||
import { ContactSection } from '../components/ContactSection';
|
import { ContactSection } from '../components/ContactSection';
|
||||||
import { useSite, useStatusLabel } from '../api';
|
import { useSite, useStatusLabel } from '../api';
|
||||||
import { useLang } from '../i18n/lang';
|
import { useLang } from '../i18n/lang';
|
||||||
@@ -15,8 +15,10 @@ import { MAP_PROJECTS, type MapProject } from '../data/mapProjects';
|
|||||||
|
|
||||||
export function Projects() {
|
export function Projects() {
|
||||||
const { status_labels, projects: dbProjects } = useSite();
|
const { status_labels, projects: dbProjects } = useSite();
|
||||||
const { t } = useLang();
|
const { t, lang } = useLang();
|
||||||
const save = useFieldSaver();
|
const save = useFieldSaver();
|
||||||
|
/** لنگر ابتدای فهرست — پس از تغییر صفحه، نگاه کاربر به بالا برمیگردد */
|
||||||
|
const listTopRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const saveStatusLabel = (key: string, value: string) => {
|
const saveStatusLabel = (key: string, value: string) => {
|
||||||
const label = status_labels.find((s) => s.key === key);
|
const label = status_labels.find((s) => s.key === key);
|
||||||
@@ -39,9 +41,8 @@ export function Projects() {
|
|||||||
// فایل استاتیک فقط وقتی دیتابیس خالی است جانشین میشود (محتوای قدیمی).
|
// فایل استاتیک فقط وقتی دیتابیس خالی است جانشین میشود (محتوای قدیمی).
|
||||||
const cardProjects = useMemo(() => dbProjects, [dbProjects]);
|
const cardProjects = useMemo(() => dbProjects, [dbProjects]);
|
||||||
|
|
||||||
// Filtering and Pagination State
|
// صفحهبندی شمارهدار — هر صفحه ۱۲ پروژه
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const itemsPerPage = 12;
|
const itemsPerPage = 12;
|
||||||
|
|
||||||
// Filter projects by status
|
// Filter projects by status
|
||||||
@@ -49,27 +50,32 @@ export function Projects() {
|
|||||||
return cardProjects.filter((p) => p.status === status);
|
return cardProjects.filter((p) => p.status === status);
|
||||||
}, [cardProjects, status]);
|
}, [cardProjects, status]);
|
||||||
|
|
||||||
// Currently displayed projects
|
// پروژههای همین صفحه
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filteredProjects.length / itemsPerPage));
|
||||||
const displayedProjects = useMemo(() => {
|
const displayedProjects = useMemo(() => {
|
||||||
return filteredProjects.slice(0, page * itemsPerPage);
|
const start = (page - 1) * itemsPerPage;
|
||||||
|
return filteredProjects.slice(start, start + itemsPerPage);
|
||||||
}, [filteredProjects, page]);
|
}, [filteredProjects, page]);
|
||||||
|
|
||||||
|
// با تغییر فیلتر یا کوتاهشدن فهرست، صفحه به بازهٔ معتبر برگردد
|
||||||
|
useEffect(() => {
|
||||||
|
if (page > totalPages) setPage(totalPages);
|
||||||
|
}, [page, totalPages]);
|
||||||
|
|
||||||
|
/** رفتن به صفحه و بازگرداندن نگاه کاربر به ابتدای فهرست */
|
||||||
|
const goToPage = (next: number) => {
|
||||||
|
const target = Math.min(Math.max(1, next), totalPages);
|
||||||
|
if (target === page) return;
|
||||||
|
setPage(target);
|
||||||
|
listTopRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
};
|
||||||
|
|
||||||
// Handle Tab Switch
|
// Handle Tab Switch
|
||||||
const handleTabSwitch = (newStatus: 'done' | 'progress') => {
|
const handleTabSwitch = (newStatus: 'done' | 'progress') => {
|
||||||
setStatus(newStatus);
|
setStatus(newStatus);
|
||||||
setPage(1); // Reset pagination on tab switch
|
setPage(1); // Reset pagination on tab switch
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle Load More
|
|
||||||
const handleLoadMore = () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
// Brief loading state
|
|
||||||
setTimeout(() => {
|
|
||||||
setPage((prev) => prev + 1);
|
|
||||||
setIsLoading(false);
|
|
||||||
}, 400);
|
|
||||||
};
|
|
||||||
|
|
||||||
const BASE_IMG_URL = "/seed-media/2026/09/";
|
const BASE_IMG_URL = "/seed-media/2026/09/";
|
||||||
|
|
||||||
// Resolve a project href from a MapProject entry by matching names — first in the
|
// Resolve a project href from a MapProject entry by matching names — first in the
|
||||||
@@ -81,6 +87,16 @@ export function Projects() {
|
|||||||
return dbMatch ? `/projects/${dbMatch.slug}` : undefined;
|
return dbMatch ? `/projects/${dbMatch.slug}` : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// تصاویر پاپآپ نقشه — فقط گالری پروژهٔ متناظر (تصویر شاخص در کارتهای فهرست
|
||||||
|
// و صفحهٔ پروژه دیده میشود و اینجا تکرار نمیشود)؛ نبود گالری → قاب تصویر پنهان.
|
||||||
|
const imagesFor = (mapProject: MapProject): string[] => {
|
||||||
|
const dbMatch = dbProjects.find(
|
||||||
|
(p) => p.title === mapProject.name || p.title_en === mapProject.name,
|
||||||
|
);
|
||||||
|
if (!dbMatch || !Array.isArray(dbMatch.gallery)) return [];
|
||||||
|
return dbMatch.gallery.filter((src): src is string => !!src);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHero pageKey="projects" />
|
<PageHero pageKey="projects" />
|
||||||
@@ -92,6 +108,7 @@ export function Projects() {
|
|||||||
<LazyProjectsMapAll
|
<LazyProjectsMapAll
|
||||||
projects={MAP_PROJECTS}
|
projects={MAP_PROJECTS}
|
||||||
resolveProjectHref={resolveProjectHref}
|
resolveProjectHref={resolveProjectHref}
|
||||||
|
imagesFor={imagesFor}
|
||||||
className="h-[350px] md:h-[450px] lg:h-[55vh] max-h-[600px] w-full"
|
className="h-[350px] md:h-[450px] lg:h-[55vh] max-h-[600px] w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,6 +118,8 @@ export function Projects() {
|
|||||||
<section className="bg-ink py-24 lg:py-32" aria-label={t('projectsListAria')}>
|
<section className="bg-ink py-24 lg:py-32" aria-label={t('projectsListAria')}>
|
||||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||||
|
|
||||||
|
<div ref={listTopRef} aria-hidden="true" />
|
||||||
|
|
||||||
{/* دکمههای فیلتر */}
|
{/* دکمههای فیلتر */}
|
||||||
<div className="flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label={t('projectFilterAria')}>
|
<div className="flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label={t('projectFilterAria')}>
|
||||||
{FILTERS.map((filter, index) => {
|
{FILTERS.map((filter, index) => {
|
||||||
@@ -156,22 +175,51 @@ export function Projects() {
|
|||||||
<AddItemButton resource="projects" editHrefBase="/admin/projects" />
|
<AddItemButton resource="projects" editHrefBase="/admin/projects" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Load More Button */}
|
{/* صفحهبندی شمارهدار */}
|
||||||
{displayedProjects.length < filteredProjects.length && (
|
{totalPages > 1 && (
|
||||||
<div className="mt-16 flex justify-center">
|
<nav className="mt-16 flex flex-wrap items-center justify-center gap-2" aria-label={t('paginationAria')}>
|
||||||
<PremiumButton
|
<button
|
||||||
onClick={handleLoadMore}
|
type="button"
|
||||||
disabled={isLoading}
|
onClick={() => goToPage(page - 1)}
|
||||||
className="px-10 py-4 w-48"
|
disabled={page === 1}
|
||||||
|
aria-label={t('previousPage')}
|
||||||
|
className="flex h-10 items-center gap-1.5 border border-ivory/20 px-4 text-xs font-light text-ivory/75 transition-colors duration-200 hover:border-copper hover:text-copper disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:border-ivory/20 disabled:hover:text-ivory/75"
|
||||||
>
|
>
|
||||||
{isLoading ? 'در حال بارگذاری...' : 'مشاهده بیشتر'}
|
<ChevronRightIcon className="h-3.5 w-3.5 rtl:rotate-0 ltr:rotate-180" />
|
||||||
</PremiumButton>
|
{t('previousPage')}
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
onClick={() => goToPage(n)}
|
||||||
|
aria-current={n === page ? 'page' : undefined}
|
||||||
|
className={`flex h-10 w-10 items-center justify-center border text-xs font-bold transition-colors duration-200 ${
|
||||||
|
n === page
|
||||||
|
? 'border-copper bg-copper/15 text-copper'
|
||||||
|
: 'border-ivory/20 text-ivory/70 hover:border-copper hover:text-copper'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{lang === 'fa' ? toPersianDigits(String(n)) : n}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => goToPage(page + 1)}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
aria-label={t('nextPage')}
|
||||||
|
className="flex h-10 items-center gap-1.5 border border-ivory/20 px-4 text-xs font-light text-ivory/75 transition-colors duration-200 hover:border-copper hover:text-copper disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:border-ivory/20 disabled:hover:text-ivory/75"
|
||||||
|
>
|
||||||
|
{t('nextPage')}
|
||||||
|
<ChevronLeftIcon className="h-3.5 w-3.5 rtl:rotate-0 ltr:rotate-180" />
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Gallery />
|
|
||||||
<ContactSection />
|
<ContactSection />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ export function ServicesPage() {
|
|||||||
<>
|
<>
|
||||||
<PageHero pageKey="services" />
|
<PageHero pageKey="services" />
|
||||||
|
|
||||||
<Services />
|
|
||||||
|
|
||||||
<section className="bg-char py-24 lg:py-32" aria-labelledby="process-title">
|
<section className="bg-char py-24 lg:py-32" aria-labelledby="process-title">
|
||||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||||
<SectionHeading title={heading.title} lead={heading.lead} align="center" />
|
<SectionHeading title={heading.title} lead={heading.lead} align="center" />
|
||||||
@@ -38,7 +36,11 @@ export function ServicesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Faq />
|
<Services tone="gray" />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<Faq tone="black" />
|
||||||
<ContactSection />
|
<ContactSection />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
+48
-16
@@ -1,10 +1,14 @@
|
|||||||
// تگهای سئوی سمت مرورگر — عنوان، توضیح متا، OG، توییتر، canonical، hreflang و JSON-LD.
|
// تگهای سئوی سمت مرورگر — عنوان، توضیح متا، OG، توییتر، canonical، hreflang و JSON-LD.
|
||||||
// محتوا از دیتابیس میآید؛ برای خزندههای بدون جاوااسکریپت، بکاند همان تگها را
|
// محتوا از دیتابیس میآید؛ برای خزندههای بدون جاوااسکریپت، بکاند همان تگها را
|
||||||
// سمت سرور داخل index.html تزریق میکند (app/seo_pages.py).
|
// سمت سرور داخل index.html تزریق میکند (app/seo_pages.py) و JSON-LD صفحه داخل
|
||||||
|
// همون <script id="seo-jsonld"> بازنویسی میشود — نسخهٔ تکراری ساخته نمیشود.
|
||||||
|
|
||||||
import { localeHref, langFromPathname } from './i18n/lang'
|
import { localeHref, langFromPathname } from './i18n/lang'
|
||||||
import type { Lang } from './i18n/lang'
|
import type { Lang } from './i18n/lang'
|
||||||
|
|
||||||
|
/** تصویر پیشفرض شبکههای اجتماعی — وقتی صفحه تصویر اختصاصی ندارد */
|
||||||
|
export const DEFAULT_OG_IMAGE = '/og-default.jpg'
|
||||||
|
|
||||||
export function setMetaDescription(content: string): void {
|
export function setMetaDescription(content: string): void {
|
||||||
// بدون توضیح از دیتابیس، پیشفرض استاتیک index.html دستنخورده میماند
|
// بدون توضیح از دیتابیس، پیشفرض استاتیک index.html دستنخورده میماند
|
||||||
if (!content.trim()) return
|
if (!content.trim()) return
|
||||||
@@ -38,6 +42,10 @@ function upsertMeta(attr: 'name' | 'property', key: string, content: string): vo
|
|||||||
el.setAttribute('content', content)
|
el.setAttribute('content', content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeMeta(attr: 'name' | 'property', key: string): void {
|
||||||
|
document.querySelector<HTMLMetaElement>(`meta[${attr}="${key}"]`)?.remove()
|
||||||
|
}
|
||||||
|
|
||||||
function upsertCanonical(path: string): void {
|
function upsertCanonical(path: string): void {
|
||||||
let el = document.querySelector<HTMLLinkElement>('link[rel="canonical"]')
|
let el = document.querySelector<HTMLLinkElement>('link[rel="canonical"]')
|
||||||
if (!el) {
|
if (!el) {
|
||||||
@@ -48,15 +56,23 @@ function upsertCanonical(path: string): void {
|
|||||||
el.setAttribute('href', new URL(path, window.location.origin).href)
|
el.setAttribute('href', new URL(path, window.location.origin).href)
|
||||||
}
|
}
|
||||||
|
|
||||||
const HREFLANGS: [Lang, string][] = [
|
/** لینک alternate با hreflang مشخص را حذف میکند — وقتی ترجمهای وجود ندارد */
|
||||||
['fa', 'fa'],
|
function removeAlternate(hreflang: string): void {
|
||||||
['en', 'en'],
|
document.querySelector<HTMLLinkElement>(`link[data-hreflang="${hreflang}"]`)?.remove()
|
||||||
['fa', 'x-default'],
|
}
|
||||||
]
|
|
||||||
|
|
||||||
/** لینکهای alternate دو زبانه — مسیر جاری و معادل آن در زبان دیگر */
|
/** لینکهای alternate دو زبانه — مسیر جاری و معادل آن در زبان دیگر.
|
||||||
function upsertAlternates(path: string): void {
|
* مجموعهٔ کامل (fa/en/x-default) فقط وقتی اعلام میشود که ترجمهٔ انگلیسی واقعاً
|
||||||
for (const [targetLang, hreflang] of HREFLANGS) {
|
* وجود داشته باشد؛ مجموعهٔ ناسازگار کل hreflang را بیاعتبار میکند. */
|
||||||
|
function upsertAlternates(path: string, lang: Lang, hasEn: boolean): void {
|
||||||
|
// صفحهٔ انگلیسیِ ترجمهنشده هیچ ادعایی ندارد؛ صفحهٔ فارسی فقط خودش و x-default
|
||||||
|
const targets: [Lang, string][] = hasEn
|
||||||
|
? [['fa', 'fa'], ['en', 'en'], ['fa', 'x-default']]
|
||||||
|
: lang === 'fa'
|
||||||
|
? [['fa', 'fa'], ['fa', 'x-default']]
|
||||||
|
: []
|
||||||
|
if (!hasEn) removeAlternate('en')
|
||||||
|
for (const [targetLang, hreflang] of targets) {
|
||||||
// x-default همیشه نسخه فارسی است
|
// x-default همیشه نسخه فارسی است
|
||||||
const target = localeHref(path, targetLang)
|
const target = localeHref(path, targetLang)
|
||||||
const attr = `data-hreflang="${hreflang}"`
|
const attr = `data-hreflang="${hreflang}"`
|
||||||
@@ -93,12 +109,14 @@ export type SeoInput = {
|
|||||||
description?: string
|
description?: string
|
||||||
/** مسیر canonical — پیشفرض مسیر جاری مرورگر */
|
/** مسیر canonical — پیشفرض مسیر جاری مرورگر */
|
||||||
path?: string
|
path?: string
|
||||||
/** تصویر OG — مسیر نسبی مثل /media/... به آدرس مطلق تبدیل میشود */
|
/** تصویر OG — مسیر نسبی مثل /media/... به آدرس مطلق تبدیل میشود؛ بدون آن تصویر پیشفرض برند */
|
||||||
image?: string
|
image?: string
|
||||||
type?: 'website' | 'article'
|
type?: 'website' | 'article'
|
||||||
/** زبان جاری — برای og:locale و لینکهای hreflang */
|
/** زبان جاری — برای og:locale و لینکهای hreflang */
|
||||||
lang?: Lang
|
lang?: Lang
|
||||||
/** اسکیمای JSON-LD — یک شیء یا آرایهای از چند اسکیما (مثل Article + FAQPage) */
|
/** آیا ترجمهٔ انگلیسی همین صفحه واقعاً وجود دارد؟ (پیشفرض: بله — مثل رفتار قبلی) */
|
||||||
|
hasEn?: boolean
|
||||||
|
/** اسکیمای JSON-LD — یک شیء یا آرایهای از چند اسکیما (مثل Article + Breadcrumb + FAQPage) */
|
||||||
jsonLd?: Record<string, unknown> | Record<string, unknown>[] | null
|
jsonLd?: Record<string, unknown> | Record<string, unknown>[] | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +126,7 @@ export function applySeo(input: SeoInput): void {
|
|||||||
const description = (input.description ?? '').trim()
|
const description = (input.description ?? '').trim()
|
||||||
const path = input.path ?? window.location.pathname
|
const path = input.path ?? window.location.pathname
|
||||||
const image = (input.image ?? '').trim()
|
const image = (input.image ?? '').trim()
|
||||||
|
const hasEn = input.hasEn ?? true
|
||||||
const lang: Lang = input.lang ?? langFromPathname(path)
|
const lang: Lang = input.lang ?? langFromPathname(path)
|
||||||
|
|
||||||
document.title = title
|
document.title = title
|
||||||
@@ -118,12 +137,25 @@ export function applySeo(input: SeoInput): void {
|
|||||||
upsertMeta('property', 'og:type', input.type ?? 'website')
|
upsertMeta('property', 'og:type', input.type ?? 'website')
|
||||||
upsertMeta('property', 'og:url', new URL(path, window.location.origin).href)
|
upsertMeta('property', 'og:url', new URL(path, window.location.origin).href)
|
||||||
upsertMeta('property', 'og:locale', lang === 'en' ? 'en_US' : 'fa_IR')
|
upsertMeta('property', 'og:locale', lang === 'en' ? 'en_US' : 'fa_IR')
|
||||||
|
upsertMeta('property', 'og:locale:alternate', lang === 'en' ? 'fa_IR' : 'en_US')
|
||||||
upsertMeta('name', 'twitter:card', 'summary_large_image')
|
upsertMeta('name', 'twitter:card', 'summary_large_image')
|
||||||
if (image) {
|
upsertMeta('name', 'twitter:title', title)
|
||||||
upsertMeta('property', 'og:image', new URL(image, window.location.origin).href)
|
upsertMeta('name', 'twitter:description', description)
|
||||||
} else {
|
// تصویر OG — تصویر اختصاصی صفحه یا تصویر پیشفرض برند
|
||||||
document.querySelector<HTMLMetaElement>('meta[property="og:image"]')?.remove()
|
{
|
||||||
|
const ogImage = new URL(image || DEFAULT_OG_IMAGE, window.location.origin).href
|
||||||
|
upsertMeta('property', 'og:image', ogImage)
|
||||||
|
upsertMeta('name', 'twitter:image', ogImage)
|
||||||
|
upsertMeta('property', 'og:image:alt', title)
|
||||||
|
if (image) {
|
||||||
|
// ابعاد فقط برای تصویر پیشفرض قطعی است؛ تصویر اختصاصی ابعاد متغیر دارد
|
||||||
|
removeMeta('property', 'og:image:width')
|
||||||
|
removeMeta('property', 'og:image:height')
|
||||||
|
} else {
|
||||||
|
upsertMeta('property', 'og:image:width', '1200')
|
||||||
|
upsertMeta('property', 'og:image:height', '630')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
upsertAlternates(path)
|
upsertAlternates(path, lang, hasEn)
|
||||||
setJsonLd(input.jsonLd ?? null)
|
setJsonLd(input.jsonLd ?? null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,3 +67,41 @@ export function toISODate(value?: DateObject | Date | null): string {
|
|||||||
const pad = (n: number) => String(n).padStart(2, '0')
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
return `${gregorianDate.year}-${pad(gregorianDate.month.number)}-${pad(gregorianDate.day)}`
|
return `${gregorianDate.year}-${pad(gregorianDate.month.number)}-${pad(gregorianDate.day)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** تاریخ مقاله → ISO میلادی برای datePublished اسکیمای JSON-LD؛ تاریخ نامفهوم → null.
|
||||||
|
* هم ISO ذخیرهشده از انتخابگر پنل و هم شمسی دستنویس («۲۴ مرداد ۱۴۰۳») را میفهمد.
|
||||||
|
* همارز تابع _parse_site_date سمت بکاند (app/seo_pages.py) است. */
|
||||||
|
export function toPublishedISO(value?: string | null): string | null {
|
||||||
|
const raw = (value ?? '').trim()
|
||||||
|
if (!raw) return null
|
||||||
|
|
||||||
|
if (ISO_DATE.test(raw)) {
|
||||||
|
const parsed = new Date(raw)
|
||||||
|
if (!Number.isNaN(parsed.getTime())) return parsed.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// شمسی دستنویس — ارقام فارسی به لاتین، نام ماه به شماره
|
||||||
|
const latin = raw.replace(/[۰-۹]/g, (c) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(c)))
|
||||||
|
const m = latin.match(/^(\d{1,2})\s*([\u0600-\u06FF]+)\s*(\d{4})$/)
|
||||||
|
const faMonths: Record<string, number> = {
|
||||||
|
'فروردین': 1, 'اردیبهشت': 2, 'خرداد': 3, 'تیر': 4, 'مرداد': 5, 'شهریور': 6,
|
||||||
|
'مهر': 7, 'آبان': 8, 'آذر': 9, 'دی': 10, 'بهمن': 11, 'اسفند': 12,
|
||||||
|
}
|
||||||
|
const month = m ? faMonths[m[2]] : undefined
|
||||||
|
if (!m || !month) return null
|
||||||
|
|
||||||
|
const jalali = new DateObject({ calendar: persian }).set({
|
||||||
|
year: Number(m[3]),
|
||||||
|
month,
|
||||||
|
day: Number(m[1]),
|
||||||
|
})
|
||||||
|
return jalali.isValid ? toISODate(jalali) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** تاریخ میلادی انگلیسی برای سایت /en — با محافظ مقادیر نامعتبر/خالی */
|
||||||
|
export function formatEnDate(value?: string | null): string {
|
||||||
|
if (!value) return ''
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return ''
|
||||||
|
return new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).format(date)
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ export function docToText(body: unknown): string {
|
|||||||
return parts.join(' ')
|
return parts.join(' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** آیا سند متن واقعی دارد؟ (سند خالی پوسته یا null: false) */
|
||||||
|
export function docHasText(body: unknown): boolean {
|
||||||
|
return docToText(body).trim().length > 0
|
||||||
|
}
|
||||||
|
|
||||||
/** متن تیترهای یک سطح مشخص (پیشفرض H2) */
|
/** متن تیترهای یک سطح مشخص (پیشفرض H2) */
|
||||||
export function docHeadings(body: unknown, level = 2): string[] {
|
export function docHeadings(body: unknown, level = 2): string[] {
|
||||||
if (Array.isArray(body)) return []
|
if (Array.isArray(body)) return []
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# چکلیست انتشار عمومی (روز «همهسنگ») 🚀
|
||||||
|
|
||||||
|
سایت تا قبل از انتشار در **حالت پیشنمایش خصوصی** است: همهچیز پشت Basic Auth با
|
||||||
|
`X-Robots-Tag: noindex` و `robots.txt: Disallow: /` و sitemap خالی. همهٔ کارهای سئو
|
||||||
|
(متا، JSON-LD، llms.txt، sitemap) همین حالا ساخته و تست شدهاند و با برداشتن گیت
|
||||||
|
بهصورت خودکار فعال میشوند — هیچ تغییر کدی برای انتشار لازم نیست.
|
||||||
|
|
||||||
|
## ۱. پیش از انتشار — بازبینی محتوا
|
||||||
|
|
||||||
|
- [ ] ترجمهٔ انگلیسی صفحههای اصلی کامل است (متای سئوی انگلیسی هر صفحه در پنل → «سئوی صفحه» ثبت شده باشد — فقط صفحاتی که متای انگلیسی دارند بهعنوان alternate معرفی میشوند)
|
||||||
|
- [ ] توضیحات سئو (Meta Description) مقالههای مهم در پنل نوشته شده است (۱۵۰–۱۶۰ نویسه)
|
||||||
|
- [ ] نام نویسندهٔ مقالهها در پنل ثبت شده (در اسکیمای Article میآید)
|
||||||
|
- [ ] تصویر پیشفرض OG (`frontend/public/og-default.jpg`) در بیلد هست
|
||||||
|
- [ ] شماره تلفن و ایمیل برند در پنل درست است (در JSON-LD تماس میآید)
|
||||||
|
- [ ] آدرس دفاتر در پنل کامل است (اسکیمای LocalBusiness از همان میسازد)
|
||||||
|
|
||||||
|
## ۲. برداشتن گیت و استقرار
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/preview-mode.sh off # حذف Basic Auth + اجازهٔ ایندکس + llms.txt فعال
|
||||||
|
bash scripts/stack.sh deploy-prod # استقرار مجدد
|
||||||
|
```
|
||||||
|
|
||||||
|
## ۳. راستیآزمایی پس از انتشار (بدون احراز هویت)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DOMAIN=your-domain.ir
|
||||||
|
# هدر noindex نباید باشد:
|
||||||
|
curl -sI https://$DOMAIN/ | grep -i x-robots-tag # ← باید خالی باشد
|
||||||
|
# robots.txt واقعی با سیاست خزندههای AI و خط Sitemap:
|
||||||
|
curl -s https://$DOMAIN/robots.txt
|
||||||
|
# sitemap پُر با alternates دو زبانه:
|
||||||
|
curl -s https://$DOMAIN/sitemap.xml | head -30
|
||||||
|
# llms.txt باید ۲۰۰ بدهد:
|
||||||
|
curl -sI https://$DOMAIN/llms.txt | head -1
|
||||||
|
# متای تزریقی در HTML خام صفحهٔ اصلی (بدون جاوااسکریپت):
|
||||||
|
curl -s https://$DOMAIN/ | grep -E 'canonical|og:image|hreflang|ld\+json' | head
|
||||||
|
# صفحهٔ ناموجود باید ۴۰۴ بدهد (نه soft-404):
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' https://$DOMAIN/blog/does-not-exist
|
||||||
|
# گیت حذف شده باشد (انتظار: 200، نه 401):
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' https://$DOMAIN/
|
||||||
|
```
|
||||||
|
|
||||||
|
## ۴. دامنهٔ دوم
|
||||||
|
|
||||||
|
- [ ] در `.env`: `ALIAS_REDIRECT=on` (ریدایرکت ۳۰۱ دامنهٔ دوم به اصلی — جلوگیری از محتوای تکراری) و deploy مجدد
|
||||||
|
|
||||||
|
## ۵. ثبت در موتورهای جستوجو
|
||||||
|
|
||||||
|
- [ ] **Google Search Console**: مالکیت دامنه، ارسال `https://DOMAIN/sitemap.xml`
|
||||||
|
- [ ] **Bing Webmaster Tools**: ایمپورت از GSC، ارسال sitemap (ایندکس Bing = منبع ChatGPT و Copilot)
|
||||||
|
- [ ] اختیاری — **IndexNow** برای ایندکس سریعتر Bing
|
||||||
|
|
||||||
|
## ۶. آمار (اختیاری ولی توصیهشده)
|
||||||
|
|
||||||
|
- [ ] در `.env`: `VITE_UMAMI_SRC` و `VITE_UMAMI_WEBSITE_ID` را تنظیم و فرانتاند را بیلد کنید (تحلیل رفتار در حال حاضر خاموش است)
|
||||||
|
- [ ] کانال «AI Assistants» در GA4/Umami برای دیدن ترافیک ارجاعی از دستیارها
|
||||||
|
|
||||||
|
## ۷. خط پایه برای پایش بعدی
|
||||||
|
|
||||||
|
- [ ] یک ممیز کامل سئو بگیرید (مثلاً پلاگین `claude-seo`: `/seo audit`) و امتیاز پایه را ثبت کنید
|
||||||
|
- [ ] اسنپشات drift بگیرید تا تغییرات آیندهٔ تایتل/متا/اسکیما قابل رهگیری باشد (`/seo drift baseline`)
|
||||||
|
- [ ] هر ۲ هفته: ۵ پرسش برند + ۵ پرسش دستهای در ChatGPT/Claude/Perplexity تست کنید — «آیا به آرامش ارجاع میدهند؟» و نرخ ارجاع را یادداشت کنید
|
||||||
|
|
||||||
|
## برگشت به عقب (اگر لازم شد)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/preview-mode.sh on # برگشت به حالت خصوصی
|
||||||
|
bash scripts/stack.sh deploy-prod
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user