Compare commits
2
Commits
bf3c67f405
...
dbc4be3052
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbc4be3052 | ||
|
|
6e422ec3dc |
+94
-27
@@ -5,6 +5,7 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
import boto3
|
import boto3
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||||
|
from sqlalchemy import func
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -15,57 +16,82 @@ from ..database import get_db
|
|||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
ArticleIn,
|
ArticleIn,
|
||||||
ArticleOut,
|
ArticleOut,
|
||||||
|
ArticlePatch,
|
||||||
BeforeAfterIn,
|
BeforeAfterIn,
|
||||||
BeforeAfterOut,
|
BeforeAfterOut,
|
||||||
|
BeforeAfterPatch,
|
||||||
BrandIn,
|
BrandIn,
|
||||||
BrandOut,
|
BrandOut,
|
||||||
|
BrandPatch,
|
||||||
ContactSectionIn,
|
ContactSectionIn,
|
||||||
ContactSectionOut,
|
ContactSectionOut,
|
||||||
|
ContactSectionPatch,
|
||||||
ContactMessageOut,
|
ContactMessageOut,
|
||||||
FaqIn,
|
FaqIn,
|
||||||
FaqOut,
|
FaqOut,
|
||||||
|
FaqPatch,
|
||||||
FooterIn,
|
FooterIn,
|
||||||
FooterOut,
|
FooterOut,
|
||||||
|
FooterPatch,
|
||||||
GalleryImageIn,
|
GalleryImageIn,
|
||||||
GalleryImageOut,
|
GalleryImageOut,
|
||||||
|
GalleryImagePatch,
|
||||||
HeroIn,
|
HeroIn,
|
||||||
HeroOut,
|
HeroOut,
|
||||||
|
HeroPatch,
|
||||||
InvestmentModelIn,
|
InvestmentModelIn,
|
||||||
InvestmentModelOut,
|
InvestmentModelOut,
|
||||||
|
InvestmentModelPatch,
|
||||||
LoginIn,
|
LoginIn,
|
||||||
MessageReadIn,
|
MessageReadIn,
|
||||||
NavItemIn,
|
NavItemIn,
|
||||||
NavItemOut,
|
NavItemOut,
|
||||||
|
NavItemPatch,
|
||||||
NewsletterSectionIn,
|
NewsletterSectionIn,
|
||||||
NewsletterOut,
|
NewsletterOut,
|
||||||
|
NewsletterSectionPatch,
|
||||||
OfficeIn,
|
OfficeIn,
|
||||||
OfficeOut,
|
OfficeOut,
|
||||||
|
OfficePatch,
|
||||||
PageHeroIn,
|
PageHeroIn,
|
||||||
PageHeroOut,
|
PageHeroOut,
|
||||||
|
PageHeroPatch,
|
||||||
ProcessStepIn,
|
ProcessStepIn,
|
||||||
ProcessStepOut,
|
ProcessStepOut,
|
||||||
|
ProcessStepPatch,
|
||||||
ProjectIn,
|
ProjectIn,
|
||||||
ProjectOut,
|
ProjectOut,
|
||||||
|
ProjectPatch,
|
||||||
|
ReorderIn,
|
||||||
SectionHeadingIn,
|
SectionHeadingIn,
|
||||||
SectionHeadingOut,
|
SectionHeadingOut,
|
||||||
|
SectionHeadingPatch,
|
||||||
ServiceIn,
|
ServiceIn,
|
||||||
ServiceOut,
|
ServiceOut,
|
||||||
|
ServicePatch,
|
||||||
StatIn,
|
StatIn,
|
||||||
StatOut,
|
StatOut,
|
||||||
|
StatPatch,
|
||||||
StatusLabelIn,
|
StatusLabelIn,
|
||||||
StatusLabelOut,
|
StatusLabelOut,
|
||||||
|
StatusLabelPatch,
|
||||||
StoryIn,
|
StoryIn,
|
||||||
StoryOut,
|
StoryOut,
|
||||||
|
StoryPatch,
|
||||||
SubscriberOut,
|
SubscriberOut,
|
||||||
TeamMemberIn,
|
TeamMemberIn,
|
||||||
TeamMemberOut,
|
TeamMemberOut,
|
||||||
|
TeamMemberPatch,
|
||||||
TestimonialIn,
|
TestimonialIn,
|
||||||
TestimonialOut,
|
TestimonialOut,
|
||||||
|
TestimonialPatch,
|
||||||
TimelineItemIn,
|
TimelineItemIn,
|
||||||
TimelineItemOut,
|
TimelineItemOut,
|
||||||
|
TimelineItemPatch,
|
||||||
TokenOut,
|
TokenOut,
|
||||||
WhyUsItemIn,
|
WhyUsItemIn,
|
||||||
WhyUsItemOut,
|
WhyUsItemOut,
|
||||||
|
WhyUsItemPatch,
|
||||||
)
|
)
|
||||||
|
|
||||||
# روتر عمومی برای ورود — بدون نیاز به توکن
|
# روتر عمومی برای ورود — بدون نیاز به توکن
|
||||||
@@ -86,16 +112,25 @@ def login(payload: LoginIn, db: Session = Depends(get_db)):
|
|||||||
return TokenOut(access_token=create_access_token(user.username))
|
return TokenOut(access_token=create_access_token(user.username))
|
||||||
|
|
||||||
|
|
||||||
def register_crud(path: str, model, out_schema, create_schema) -> None:
|
def register_crud(path: str, model, out_schema, create_schema, patch_schema=None) -> None:
|
||||||
"""ثبت پنج مسیر CRUD استاندارد (لیست، ساخت، دریافت، ویرایش، حذف) روی روتر مدیریت."""
|
"""ثبت مسیرهای استاندارد CRUD (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی،
|
||||||
|
حذف و ترتیببندی) روی روتر مدیریت."""
|
||||||
|
|
||||||
@router.get(f"/{path}", response_model=list[out_schema])
|
@router.get(f"/{path}", response_model=list[out_schema])
|
||||||
def list_items(db: Session = Depends(get_db)):
|
def list_items(db: Session = Depends(get_db)):
|
||||||
return db.query(model).order_by(model.id).all()
|
query = db.query(model)
|
||||||
|
if hasattr(model, "position"):
|
||||||
|
query = query.order_by(model.position, model.id)
|
||||||
|
else:
|
||||||
|
query = query.order_by(model.id)
|
||||||
|
return query.all()
|
||||||
|
|
||||||
@router.post(f"/{path}", response_model=out_schema, status_code=201)
|
@router.post(f"/{path}", response_model=out_schema, status_code=201)
|
||||||
def create_item(payload: create_schema, db: Session = Depends(get_db)):
|
def create_item(payload: create_schema, db: Session = Depends(get_db)):
|
||||||
item = model(**payload.model_dump())
|
item = model(**payload.model_dump())
|
||||||
|
if hasattr(model, "position"):
|
||||||
|
# آیتم جدید در انتهای لیست قرار میگیرد
|
||||||
|
item.position = (db.query(func.max(model.position)).scalar() or 0) + 1
|
||||||
db.add(item)
|
db.add(item)
|
||||||
try:
|
try:
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -127,6 +162,38 @@ def register_crud(path: str, model, out_schema, create_schema) -> None:
|
|||||||
db.refresh(item)
|
db.refresh(item)
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
if patch_schema is not None:
|
||||||
|
|
||||||
|
@router.patch(f"/{path}/{{item_id}}", response_model=out_schema)
|
||||||
|
def patch_item(item_id: int, payload: patch_schema, db: Session = Depends(get_db)):
|
||||||
|
"""ویرایش جزئی — فقط فیلدهای ارسالشده بهروزرسانی میشوند (برای ویرایش زنده)."""
|
||||||
|
item = db.get(model, item_id)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
|
||||||
|
for key, value in payload.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(item, key, value)
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=409, detail="رکوردی با این مقدار تکراری وجود دارد (مثلاً slug یا ایمیل)")
|
||||||
|
db.refresh(item)
|
||||||
|
return item
|
||||||
|
|
||||||
|
@router.post(f"/{path}/reorder", response_model=list[out_schema])
|
||||||
|
def reorder_items(payload: ReorderIn, db: Session = Depends(get_db)):
|
||||||
|
"""ذخیره ترتیب جدید آیتمها بر اساس لیست idهای ارسالی."""
|
||||||
|
if not hasattr(model, "position"):
|
||||||
|
raise HTTPException(status_code=400, detail="این منبع از ترتیببندی پشتیبانی نمیکند")
|
||||||
|
items = db.query(model).filter(model.id.in_(payload.ids)).all()
|
||||||
|
if len(items) != len(payload.ids):
|
||||||
|
raise HTTPException(status_code=404, detail="برخی رکوردها یافت نشدند")
|
||||||
|
by_id = {item.id: item for item in items}
|
||||||
|
for pos, item_id in enumerate(payload.ids):
|
||||||
|
by_id[item_id].position = pos
|
||||||
|
db.commit()
|
||||||
|
return db.query(model).order_by(model.position, model.id).all()
|
||||||
|
|
||||||
@router.delete(f"/{path}/{{item_id}}", status_code=204)
|
@router.delete(f"/{path}/{{item_id}}", status_code=204)
|
||||||
def delete_item(item_id: int, db: Session = Depends(get_db)):
|
def delete_item(item_id: int, db: Session = Depends(get_db)):
|
||||||
item = db.get(model, item_id)
|
item = db.get(model, item_id)
|
||||||
@@ -137,30 +204,30 @@ def register_crud(path: str, model, out_schema, create_schema) -> None:
|
|||||||
|
|
||||||
|
|
||||||
# CRUD محتوا
|
# CRUD محتوا
|
||||||
register_crud("brand", models.Brand, BrandOut, BrandIn)
|
register_crud("brand", models.Brand, BrandOut, BrandIn, BrandPatch)
|
||||||
register_crud("nav", models.NavItem, NavItemOut, NavItemIn)
|
register_crud("nav", models.NavItem, NavItemOut, NavItemIn, NavItemPatch)
|
||||||
register_crud("hero", models.Hero, HeroOut, HeroIn)
|
register_crud("hero", models.Hero, HeroOut, HeroIn, HeroPatch)
|
||||||
register_crud("story", models.StorySection, StoryOut, StoryIn)
|
register_crud("story", models.StorySection, StoryOut, StoryIn, StoryPatch)
|
||||||
register_crud("section-headings", models.SectionHeadingContent, SectionHeadingOut, SectionHeadingIn)
|
register_crud("section-headings", models.SectionHeadingContent, SectionHeadingOut, SectionHeadingIn, SectionHeadingPatch)
|
||||||
register_crud("page-heroes", models.PageHeroContent, PageHeroOut, PageHeroIn)
|
register_crud("page-heroes", models.PageHeroContent, PageHeroOut, PageHeroIn, PageHeroPatch)
|
||||||
register_crud("services", models.Service, ServiceOut, ServiceIn)
|
register_crud("services", models.Service, ServiceOut, ServiceIn, ServicePatch)
|
||||||
register_crud("projects", models.Project, ProjectOut, ProjectIn)
|
register_crud("projects", models.Project, ProjectOut, ProjectIn, ProjectPatch)
|
||||||
register_crud("articles", models.Article, ArticleOut, ArticleIn)
|
register_crud("articles", models.Article, ArticleOut, ArticleIn, ArticlePatch)
|
||||||
register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn)
|
register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn, TestimonialPatch)
|
||||||
register_crud("faqs", models.Faq, FaqOut, FaqIn)
|
register_crud("faqs", models.Faq, FaqOut, FaqIn, FaqPatch)
|
||||||
register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn)
|
register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn, TeamMemberPatch)
|
||||||
register_crud("timeline", models.TimelineItem, TimelineItemOut, TimelineItemIn)
|
register_crud("timeline", models.TimelineItem, TimelineItemOut, TimelineItemIn, TimelineItemPatch)
|
||||||
register_crud("stats", models.Stat, StatOut, StatIn)
|
register_crud("stats", models.Stat, StatOut, StatIn, StatPatch)
|
||||||
register_crud("why-us", models.WhyUsItem, WhyUsItemOut, WhyUsItemIn)
|
register_crud("why-us", models.WhyUsItem, WhyUsItemOut, WhyUsItemIn, WhyUsItemPatch)
|
||||||
register_crud("before-after", models.BeforeAfter, BeforeAfterOut, BeforeAfterIn)
|
register_crud("before-after", models.BeforeAfter, BeforeAfterOut, BeforeAfterIn, BeforeAfterPatch)
|
||||||
register_crud("investment-models", models.InvestmentModel, InvestmentModelOut, InvestmentModelIn)
|
register_crud("investment-models", models.InvestmentModel, InvestmentModelOut, InvestmentModelIn, InvestmentModelPatch)
|
||||||
register_crud("process-steps", models.ProcessStep, ProcessStepOut, ProcessStepIn)
|
register_crud("process-steps", models.ProcessStep, ProcessStepOut, ProcessStepIn, ProcessStepPatch)
|
||||||
register_crud("offices", models.Office, OfficeOut, OfficeIn)
|
register_crud("offices", models.Office, OfficeOut, OfficeIn, OfficePatch)
|
||||||
register_crud("gallery", models.GalleryImage, GalleryImageOut, GalleryImageIn)
|
register_crud("gallery", models.GalleryImage, GalleryImageOut, GalleryImageIn, GalleryImagePatch)
|
||||||
register_crud("status-labels", models.StatusLabel, StatusLabelOut, StatusLabelIn)
|
register_crud("status-labels", models.StatusLabel, StatusLabelOut, StatusLabelIn, StatusLabelPatch)
|
||||||
register_crud("contact-section", models.ContactSectionContent, ContactSectionOut, ContactSectionIn)
|
register_crud("contact-section", models.ContactSectionContent, ContactSectionOut, ContactSectionIn, ContactSectionPatch)
|
||||||
register_crud("newsletter", models.NewsletterSectionContent, NewsletterOut, NewsletterSectionIn)
|
register_crud("newsletter", models.NewsletterSectionContent, NewsletterOut, NewsletterSectionIn, NewsletterSectionPatch)
|
||||||
register_crud("footer", models.FooterContent, FooterOut, FooterIn)
|
register_crud("footer", models.FooterContent, FooterOut, FooterIn, FooterPatch)
|
||||||
|
|
||||||
|
|
||||||
# آپلود تصویر به Object Storage (MinIO) — URL نسبی برمیگرداند تا بکاند ذخیرهسازی قابل تعویض باشد
|
# آپلود تصویر به Object Storage (MinIO) — URL نسبی برمیگرداند تا بکاند ذخیرهسازی قابل تعویض باشد
|
||||||
|
|||||||
+23
-23
@@ -37,12 +37,12 @@ def get_brand(db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
@router.get("/services", response_model=list[ServiceOut])
|
@router.get("/services", response_model=list[ServiceOut])
|
||||||
def get_services(db: Session = Depends(get_db)):
|
def get_services(db: Session = Depends(get_db)):
|
||||||
return db.query(models.Service).all()
|
return db.query(models.Service).order_by(models.Service.position, models.Service.id).all()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/projects", response_model=list[ProjectOut])
|
@router.get("/projects", response_model=list[ProjectOut])
|
||||||
def get_projects(db: Session = Depends(get_db)):
|
def get_projects(db: Session = Depends(get_db)):
|
||||||
return db.query(models.Project).order_by(models.Project.id).all()
|
return db.query(models.Project).order_by(models.Project.position, models.Project.id).all()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/projects/{slug}", response_model=ProjectOut)
|
@router.get("/projects/{slug}", response_model=ProjectOut)
|
||||||
@@ -55,7 +55,7 @@ def get_project(slug: str, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
@router.get("/articles", response_model=list[ArticleOut])
|
@router.get("/articles", response_model=list[ArticleOut])
|
||||||
def get_articles(db: Session = Depends(get_db)):
|
def get_articles(db: Session = Depends(get_db)):
|
||||||
return db.query(models.Article).order_by(models.Article.id).all()
|
return db.query(models.Article).order_by(models.Article.position, models.Article.id).all()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/articles/{slug}", response_model=ArticleOut)
|
@router.get("/articles/{slug}", response_model=ArticleOut)
|
||||||
@@ -68,32 +68,32 @@ def get_article(slug: str, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
@router.get("/testimonials", response_model=list[TestimonialOut])
|
@router.get("/testimonials", response_model=list[TestimonialOut])
|
||||||
def get_testimonials(db: Session = Depends(get_db)):
|
def get_testimonials(db: Session = Depends(get_db)):
|
||||||
return db.query(models.Testimonial).all()
|
return db.query(models.Testimonial).order_by(models.Testimonial.position, models.Testimonial.id).all()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/faqs", response_model=list[FaqOut])
|
@router.get("/faqs", response_model=list[FaqOut])
|
||||||
def get_faqs(db: Session = Depends(get_db)):
|
def get_faqs(db: Session = Depends(get_db)):
|
||||||
return db.query(models.Faq).all()
|
return db.query(models.Faq).order_by(models.Faq.position, models.Faq.id).all()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/team", response_model=list[TeamMemberOut])
|
@router.get("/team", response_model=list[TeamMemberOut])
|
||||||
def get_team(db: Session = Depends(get_db)):
|
def get_team(db: Session = Depends(get_db)):
|
||||||
return db.query(models.TeamMember).all()
|
return db.query(models.TeamMember).order_by(models.TeamMember.position, models.TeamMember.id).all()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/timeline", response_model=list[TimelineItemOut])
|
@router.get("/timeline", response_model=list[TimelineItemOut])
|
||||||
def get_timeline(db: Session = Depends(get_db)):
|
def get_timeline(db: Session = Depends(get_db)):
|
||||||
return db.query(models.TimelineItem).order_by(models.TimelineItem.id).all()
|
return db.query(models.TimelineItem).order_by(models.TimelineItem.position, models.TimelineItem.id).all()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats", response_model=list[StatOut])
|
@router.get("/stats", response_model=list[StatOut])
|
||||||
def get_stats(db: Session = Depends(get_db)):
|
def get_stats(db: Session = Depends(get_db)):
|
||||||
return db.query(models.Stat).all()
|
return db.query(models.Stat).order_by(models.Stat.position, models.Stat.id).all()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/why-us", response_model=list[WhyUsItemOut])
|
@router.get("/why-us", response_model=list[WhyUsItemOut])
|
||||||
def get_why_us(db: Session = Depends(get_db)):
|
def get_why_us(db: Session = Depends(get_db)):
|
||||||
return db.query(models.WhyUsItem).all()
|
return db.query(models.WhyUsItem).order_by(models.WhyUsItem.position, models.WhyUsItem.id).all()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/before-after", response_model=BeforeAfterOut)
|
@router.get("/before-after", response_model=BeforeAfterOut)
|
||||||
@@ -109,25 +109,25 @@ def get_site(db: Session = Depends(get_db)):
|
|||||||
"""همه محتوای سایت در یک درخواست — مناسب برای بارگذاری اولیه فرانتاند."""
|
"""همه محتوای سایت در یک درخواست — مناسب برای بارگذاری اولیه فرانتاند."""
|
||||||
return {
|
return {
|
||||||
"brand": db.query(models.Brand).first(),
|
"brand": db.query(models.Brand).first(),
|
||||||
"nav": db.query(models.NavItem).order_by(models.NavItem.id).all(),
|
"nav": db.query(models.NavItem).order_by(models.NavItem.position, models.NavItem.id).all(),
|
||||||
"hero": db.query(models.Hero).first(),
|
"hero": db.query(models.Hero).first(),
|
||||||
"story": db.query(models.StorySection).first(),
|
"story": db.query(models.StorySection).first(),
|
||||||
"section_headings": db.query(models.SectionHeadingContent).order_by(models.SectionHeadingContent.id).all(),
|
"section_headings": db.query(models.SectionHeadingContent).order_by(models.SectionHeadingContent.id).all(),
|
||||||
"page_heroes": db.query(models.PageHeroContent).order_by(models.PageHeroContent.id).all(),
|
"page_heroes": db.query(models.PageHeroContent).order_by(models.PageHeroContent.id).all(),
|
||||||
"services": db.query(models.Service).all(),
|
"services": db.query(models.Service).order_by(models.Service.position, models.Service.id).all(),
|
||||||
"projects": db.query(models.Project).order_by(models.Project.id).all(),
|
"projects": db.query(models.Project).order_by(models.Project.position, models.Project.id).all(),
|
||||||
"articles": db.query(models.Article).order_by(models.Article.id).all(),
|
"articles": db.query(models.Article).order_by(models.Article.position, models.Article.id).all(),
|
||||||
"testimonials": db.query(models.Testimonial).all(),
|
"testimonials": db.query(models.Testimonial).order_by(models.Testimonial.position, models.Testimonial.id).all(),
|
||||||
"faqs": db.query(models.Faq).all(),
|
"faqs": db.query(models.Faq).order_by(models.Faq.position, models.Faq.id).all(),
|
||||||
"team": db.query(models.TeamMember).all(),
|
"team": db.query(models.TeamMember).order_by(models.TeamMember.position, models.TeamMember.id).all(),
|
||||||
"timeline": db.query(models.TimelineItem).order_by(models.TimelineItem.id).all(),
|
"timeline": db.query(models.TimelineItem).order_by(models.TimelineItem.position, models.TimelineItem.id).all(),
|
||||||
"stats": db.query(models.Stat).all(),
|
"stats": db.query(models.Stat).order_by(models.Stat.position, models.Stat.id).all(),
|
||||||
"why_us": db.query(models.WhyUsItem).all(),
|
"why_us": db.query(models.WhyUsItem).order_by(models.WhyUsItem.position, models.WhyUsItem.id).all(),
|
||||||
"before_after": db.query(models.BeforeAfter).first(),
|
"before_after": db.query(models.BeforeAfter).first(),
|
||||||
"investment_models": db.query(models.InvestmentModel).order_by(models.InvestmentModel.id).all(),
|
"investment_models": db.query(models.InvestmentModel).order_by(models.InvestmentModel.position, models.InvestmentModel.id).all(),
|
||||||
"process_steps": db.query(models.ProcessStep).order_by(models.ProcessStep.id).all(),
|
"process_steps": db.query(models.ProcessStep).order_by(models.ProcessStep.position, models.ProcessStep.id).all(),
|
||||||
"offices": db.query(models.Office).order_by(models.Office.id).all(),
|
"offices": db.query(models.Office).order_by(models.Office.position, models.Office.id).all(),
|
||||||
"gallery": db.query(models.GalleryImage).order_by(models.GalleryImage.id).all(),
|
"gallery": db.query(models.GalleryImage).order_by(models.GalleryImage.position, models.GalleryImage.id).all(),
|
||||||
"status_labels": db.query(models.StatusLabel).order_by(models.StatusLabel.id).all(),
|
"status_labels": db.query(models.StatusLabel).order_by(models.StatusLabel.id).all(),
|
||||||
"contact_section": db.query(models.ContactSectionContent).first(),
|
"contact_section": db.query(models.ContactSectionContent).first(),
|
||||||
"newsletter": db.query(models.NewsletterSectionContent).first(),
|
"newsletter": db.query(models.NewsletterSectionContent).first(),
|
||||||
|
|||||||
+21
-1
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, text
|
||||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
@@ -18,3 +18,23 @@ def get_db():
|
|||||||
yield db
|
yield db
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# جدولهای مجموعهای که ستون position دارند
|
||||||
|
POSITION_TABLES = [
|
||||||
|
"services", "projects", "articles", "testimonials", "faqs", "team",
|
||||||
|
"timeline", "stats", "why_us", "investment_models", "process_steps",
|
||||||
|
"offices", "gallery_images", "nav_items",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def run_light_migrations() -> None:
|
||||||
|
"""مهاجرتهای سبک idempotent — برای دیتابیسهای موجود که create_all آنها را
|
||||||
|
بهروز نمیکند اجرا میشود (seed و startup هر دو آن را صدا میزنند)."""
|
||||||
|
if engine.dialect.name != "postgresql":
|
||||||
|
return
|
||||||
|
with engine.begin() as conn:
|
||||||
|
for table in POSITION_TABLES:
|
||||||
|
conn.execute(
|
||||||
|
text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS position INTEGER NOT NULL DEFAULT 0")
|
||||||
|
)
|
||||||
|
|||||||
+2
-1
@@ -4,9 +4,10 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from . import models # noqa: F401 — ثبت مدلها پیش از ساخت جداول
|
from . import models # noqa: F401 — ثبت مدلها پیش از ساخت جداول
|
||||||
from .api import admin, forms, public
|
from .api import admin, forms, public
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .database import Base, engine
|
from .database import Base, engine, run_light_migrations
|
||||||
|
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
run_light_migrations()
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Aramesh Building Group API",
|
title="Aramesh Building Group API",
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ class Brand(Base):
|
|||||||
class Service(Base):
|
class Service(Base):
|
||||||
__tablename__ = "services"
|
__tablename__ = "services"
|
||||||
|
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
title: Mapped[str] = mapped_column(String(256))
|
title: Mapped[str] = mapped_column(String(256))
|
||||||
desc: Mapped[str] = mapped_column(Text, default="")
|
desc: Mapped[str] = mapped_column(Text, default="")
|
||||||
@@ -44,6 +46,8 @@ class Service(Base):
|
|||||||
class Project(Base):
|
class Project(Base):
|
||||||
__tablename__ = "projects"
|
__tablename__ = "projects"
|
||||||
|
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||||
title: Mapped[str] = mapped_column(String(256))
|
title: Mapped[str] = mapped_column(String(256))
|
||||||
@@ -63,6 +67,8 @@ class Project(Base):
|
|||||||
class Article(Base):
|
class Article(Base):
|
||||||
__tablename__ = "articles"
|
__tablename__ = "articles"
|
||||||
|
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||||
title: Mapped[str] = mapped_column(String(256))
|
title: Mapped[str] = mapped_column(String(256))
|
||||||
@@ -77,6 +83,8 @@ class Article(Base):
|
|||||||
class Testimonial(Base):
|
class Testimonial(Base):
|
||||||
__tablename__ = "testimonials"
|
__tablename__ = "testimonials"
|
||||||
|
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
name: Mapped[str] = mapped_column(String(128))
|
name: Mapped[str] = mapped_column(String(128))
|
||||||
role: Mapped[str] = mapped_column(String(128), default="")
|
role: Mapped[str] = mapped_column(String(128), default="")
|
||||||
@@ -86,6 +94,8 @@ class Testimonial(Base):
|
|||||||
class Faq(Base):
|
class Faq(Base):
|
||||||
__tablename__ = "faqs"
|
__tablename__ = "faqs"
|
||||||
|
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
question: Mapped[str] = mapped_column(Text)
|
question: Mapped[str] = mapped_column(Text)
|
||||||
answer: Mapped[str] = mapped_column(Text, default="")
|
answer: Mapped[str] = mapped_column(Text, default="")
|
||||||
@@ -94,6 +104,8 @@ class Faq(Base):
|
|||||||
class TeamMember(Base):
|
class TeamMember(Base):
|
||||||
__tablename__ = "team"
|
__tablename__ = "team"
|
||||||
|
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
name: Mapped[str] = mapped_column(String(128))
|
name: Mapped[str] = mapped_column(String(128))
|
||||||
role: Mapped[str] = mapped_column(String(128), default="")
|
role: Mapped[str] = mapped_column(String(128), default="")
|
||||||
@@ -103,6 +115,8 @@ class TeamMember(Base):
|
|||||||
class TimelineItem(Base):
|
class TimelineItem(Base):
|
||||||
__tablename__ = "timeline"
|
__tablename__ = "timeline"
|
||||||
|
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
year: Mapped[str] = mapped_column(String(32))
|
year: Mapped[str] = mapped_column(String(32))
|
||||||
title: Mapped[str] = mapped_column(String(256))
|
title: Mapped[str] = mapped_column(String(256))
|
||||||
@@ -113,6 +127,8 @@ class TimelineItem(Base):
|
|||||||
class Stat(Base):
|
class Stat(Base):
|
||||||
__tablename__ = "stats"
|
__tablename__ = "stats"
|
||||||
|
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
value: Mapped[int] = mapped_column(Integer)
|
value: Mapped[int] = mapped_column(Integer)
|
||||||
suffix: Mapped[str] = mapped_column(String(16), default="")
|
suffix: Mapped[str] = mapped_column(String(16), default="")
|
||||||
@@ -122,6 +138,8 @@ class Stat(Base):
|
|||||||
class WhyUsItem(Base):
|
class WhyUsItem(Base):
|
||||||
__tablename__ = "why_us"
|
__tablename__ = "why_us"
|
||||||
|
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
title: Mapped[str] = mapped_column(String(256))
|
title: Mapped[str] = mapped_column(String(256))
|
||||||
desc: Mapped[str] = mapped_column(Text, default="")
|
desc: Mapped[str] = mapped_column(Text, default="")
|
||||||
@@ -142,6 +160,8 @@ class BeforeAfter(Base):
|
|||||||
class NavItem(Base):
|
class NavItem(Base):
|
||||||
__tablename__ = "nav_items"
|
__tablename__ = "nav_items"
|
||||||
|
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
label: Mapped[str] = mapped_column(String(64))
|
label: Mapped[str] = mapped_column(String(64))
|
||||||
href: Mapped[str] = mapped_column(String(128))
|
href: Mapped[str] = mapped_column(String(128))
|
||||||
@@ -215,6 +235,8 @@ class PageHeroContent(Base):
|
|||||||
class InvestmentModel(Base):
|
class InvestmentModel(Base):
|
||||||
__tablename__ = "investment_models"
|
__tablename__ = "investment_models"
|
||||||
|
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
title: Mapped[str] = mapped_column(String(256))
|
title: Mapped[str] = mapped_column(String(256))
|
||||||
desc: Mapped[str] = mapped_column(Text, default="")
|
desc: Mapped[str] = mapped_column(Text, default="")
|
||||||
@@ -224,6 +246,8 @@ class InvestmentModel(Base):
|
|||||||
class ProcessStep(Base):
|
class ProcessStep(Base):
|
||||||
__tablename__ = "process_steps"
|
__tablename__ = "process_steps"
|
||||||
|
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
step: Mapped[str] = mapped_column(String(16), default="")
|
step: Mapped[str] = mapped_column(String(16), default="")
|
||||||
title: Mapped[str] = mapped_column(String(256))
|
title: Mapped[str] = mapped_column(String(256))
|
||||||
@@ -233,6 +257,8 @@ class ProcessStep(Base):
|
|||||||
class Office(Base):
|
class Office(Base):
|
||||||
__tablename__ = "offices"
|
__tablename__ = "offices"
|
||||||
|
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
city: Mapped[str] = mapped_column(String(64))
|
city: Mapped[str] = mapped_column(String(64))
|
||||||
address: Mapped[str] = mapped_column(Text, default="")
|
address: Mapped[str] = mapped_column(Text, default="")
|
||||||
@@ -242,6 +268,8 @@ class Office(Base):
|
|||||||
class GalleryImage(Base):
|
class GalleryImage(Base):
|
||||||
__tablename__ = "gallery_images"
|
__tablename__ = "gallery_images"
|
||||||
|
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
src: Mapped[str] = mapped_column(Text, default="")
|
src: Mapped[str] = mapped_column(Text, default="")
|
||||||
alt: Mapped[str] = mapped_column(Text, default="")
|
alt: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
|||||||
+43
-2
@@ -1,7 +1,7 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Literal
|
from typing import Literal, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field, create_model
|
||||||
|
|
||||||
# ---------- پایه ----------
|
# ---------- پایه ----------
|
||||||
|
|
||||||
@@ -493,3 +493,44 @@ class FooterIn(BaseModel):
|
|||||||
|
|
||||||
class MessageReadIn(BaseModel):
|
class MessageReadIn(BaseModel):
|
||||||
is_read: bool = True
|
is_read: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- اسکیماهای PATCH (ویرایش جزئی — همه فیلدها اختیاری) ----------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_patch(name: str, model_cls: type[BaseModel]) -> type[BaseModel]:
|
||||||
|
"""از اسکیمای ورودی، اسکیمای PATCH میسازد؛ فیلدهای ارسالنشده دستنخورده میمانند."""
|
||||||
|
fields = {fname: (Optional[f.annotation], None) for fname, f in model_cls.model_fields.items()}
|
||||||
|
return create_model(name, **fields)
|
||||||
|
|
||||||
|
|
||||||
|
BrandPatch = _make_patch("BrandPatch", BrandIn)
|
||||||
|
ServicePatch = _make_patch("ServicePatch", ServiceIn)
|
||||||
|
ProjectPatch = _make_patch("ProjectPatch", ProjectIn)
|
||||||
|
ArticlePatch = _make_patch("ArticlePatch", ArticleIn)
|
||||||
|
TestimonialPatch = _make_patch("TestimonialPatch", TestimonialIn)
|
||||||
|
FaqPatch = _make_patch("FaqPatch", FaqIn)
|
||||||
|
TeamMemberPatch = _make_patch("TeamMemberPatch", TeamMemberIn)
|
||||||
|
TimelineItemPatch = _make_patch("TimelineItemPatch", TimelineItemIn)
|
||||||
|
StatPatch = _make_patch("StatPatch", StatIn)
|
||||||
|
WhyUsItemPatch = _make_patch("WhyUsItemPatch", WhyUsItemIn)
|
||||||
|
BeforeAfterPatch = _make_patch("BeforeAfterPatch", BeforeAfterIn)
|
||||||
|
NavItemPatch = _make_patch("NavItemPatch", NavItemIn)
|
||||||
|
HeroPatch = _make_patch("HeroPatch", HeroIn)
|
||||||
|
StoryPatch = _make_patch("StoryPatch", StoryIn)
|
||||||
|
SectionHeadingPatch = _make_patch("SectionHeadingPatch", SectionHeadingIn)
|
||||||
|
PageHeroPatch = _make_patch("PageHeroPatch", PageHeroIn)
|
||||||
|
InvestmentModelPatch = _make_patch("InvestmentModelPatch", InvestmentModelIn)
|
||||||
|
ProcessStepPatch = _make_patch("ProcessStepPatch", ProcessStepIn)
|
||||||
|
OfficePatch = _make_patch("OfficePatch", OfficeIn)
|
||||||
|
GalleryImagePatch = _make_patch("GalleryImagePatch", GalleryImageIn)
|
||||||
|
StatusLabelPatch = _make_patch("StatusLabelPatch", StatusLabelIn)
|
||||||
|
ContactSectionPatch = _make_patch("ContactSectionPatch", ContactSectionIn)
|
||||||
|
NewsletterSectionPatch = _make_patch("NewsletterSectionPatch", NewsletterSectionIn)
|
||||||
|
FooterPatch = _make_patch("FooterPatch", FooterIn)
|
||||||
|
|
||||||
|
|
||||||
|
class ReorderIn(BaseModel):
|
||||||
|
"""ترتیب جدید آیتمهای یک مجموعه بر اساس id."""
|
||||||
|
|
||||||
|
ids: list[int]
|
||||||
|
|||||||
+7
-3
@@ -14,7 +14,7 @@ from typing import Any
|
|||||||
from . import models, seed_data
|
from . import models, seed_data
|
||||||
from .auth import hash_password
|
from .auth import hash_password
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .database import Base, SessionLocal, engine
|
from .database import Base, SessionLocal, engine, run_light_migrations
|
||||||
|
|
||||||
# (مدل، دادههای اولیه، برچسب گزارش)
|
# (مدل، دادههای اولیه، برچسب گزارش)
|
||||||
CONTENT: list[tuple[Any, list[dict], str]] = [
|
CONTENT: list[tuple[Any, list[dict], str]] = [
|
||||||
@@ -53,12 +53,16 @@ def seed(force: bool = False) -> None:
|
|||||||
print("جداول قبلی حذف شدند.")
|
print("جداول قبلی حذف شدند.")
|
||||||
|
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
run_light_migrations()
|
||||||
|
|
||||||
for model, rows, label in CONTENT:
|
for model, rows, label in CONTENT:
|
||||||
if db.query(model).count() > 0:
|
if db.query(model).count() > 0:
|
||||||
continue
|
continue
|
||||||
for row in rows:
|
for idx, row in enumerate(rows):
|
||||||
db.add(model(**row))
|
payload = dict(row)
|
||||||
|
if hasattr(model, "position") and "position" not in payload:
|
||||||
|
payload["position"] = idx
|
||||||
|
db.add(model(**payload))
|
||||||
|
|
||||||
if db.query(models.AdminUser).count() == 0:
|
if db.query(models.AdminUser).count() == 0:
|
||||||
db.add(
|
db.add(
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 302 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 283 KiB |
+25
-6
@@ -16,6 +16,9 @@ import { ArticleDetail } from './pages/ArticleDetail';
|
|||||||
import { FaqPage } from './pages/FaqPage';
|
import { FaqPage } from './pages/FaqPage';
|
||||||
import { ContactPage } from './pages/ContactPage';
|
import { ContactPage } from './pages/ContactPage';
|
||||||
import { SiteProvider, useSiteData } from './api';
|
import { SiteProvider, useSiteData } from './api';
|
||||||
|
import { AdminProvider, useAdmin } from './admin/AdminContext';
|
||||||
|
import { AdminBar } from './admin/AdminBar';
|
||||||
|
import { AdminLoginPage } from './admin/AdminLoginPage';
|
||||||
|
|
||||||
function ScrollToTop() {
|
function ScrollToTop() {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
@@ -35,8 +38,9 @@ function AnimatedRoutes() {
|
|||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}>
|
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}>
|
||||||
|
|
||||||
<Routes location={location}>
|
<Routes location={location}>
|
||||||
|
<Route path="/admin/login" element={<AdminLoginPage />} />
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/about" element={<About />} />
|
<Route path="/about" element={<About />} />
|
||||||
<Route path="/services" element={<ServicesPage />} />
|
<Route path="/services" element={<ServicesPage />} />
|
||||||
@@ -54,6 +58,22 @@ function AnimatedRoutes() {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AppChrome() {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const { isAuthenticated } = useAdmin();
|
||||||
|
const isLoginPage = pathname === '/admin/login';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!isLoginPage && <Header />}
|
||||||
|
<AnimatedRoutes />
|
||||||
|
{!isLoginPage && <Footer />}
|
||||||
|
{!isLoginPage && <FloatingContact />}
|
||||||
|
{isAuthenticated && <AdminBar />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AppShell() {
|
function AppShell() {
|
||||||
const { data, error, reload } = useSiteData();
|
const { data, error, reload } = useSiteData();
|
||||||
|
|
||||||
@@ -81,10 +101,7 @@ function AppShell() {
|
|||||||
) : data ? (
|
) : data ? (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
<Header />
|
<AppChrome />
|
||||||
<AnimatedRoutes />
|
|
||||||
<Footer />
|
|
||||||
<FloatingContact />
|
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -99,7 +116,9 @@ export function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SiteProvider>
|
<SiteProvider>
|
||||||
<AppShell />
|
<AdminProvider>
|
||||||
|
<AppShell />
|
||||||
|
</AdminProvider>
|
||||||
</SiteProvider>
|
</SiteProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useAdmin } from './AdminContext'
|
||||||
|
|
||||||
|
const STATUS_TEXT: Record<string, string> = {
|
||||||
|
idle: 'همگامسازی شد',
|
||||||
|
saving: 'در حال ذخیره…',
|
||||||
|
error: 'خطا در ذخیره',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
idle: 'bg-emerald-400',
|
||||||
|
saving: 'bg-copper animate-pulse',
|
||||||
|
error: 'bg-red-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** نوار شناور ادمین — فقط برای کاربر واردشده رندر میشود */
|
||||||
|
export function AdminBar() {
|
||||||
|
const { isEditMode, toggleEditMode, saveStatus, logout } = useAdmin()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
dir="rtl"
|
||||||
|
className="fixed bottom-4 right-1/2 z-[90] flex translate-x-1/2 items-center gap-5 border border-ivory/15 bg-ink/95 py-2.5 pl-6 pr-5 shadow-2xl backdrop-blur-md"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 text-xs font-bold text-copper">
|
||||||
|
<span className="flex h-2 w-2 rounded-full bg-copper" />
|
||||||
|
حالت مدیریت
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={isEditMode}
|
||||||
|
onClick={toggleEditMode}
|
||||||
|
className="group flex items-center gap-3 text-sm font-light text-ivory/80"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition-colors duration-200 ${
|
||||||
|
isEditMode ? 'border-copper bg-copper/30' : 'border-ivory/25 bg-char'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute h-4 w-4 rounded-full transition-all duration-200 ${
|
||||||
|
isEditMode ? 'right-1 bg-copper' : 'right-6 bg-ivory/50'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{isEditMode ? 'حالت ویرایش فعال' : 'حالت ویرایش خاموش'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="flex items-center gap-2 border-r border-ivory/10 pr-5 text-xs font-light text-ivory/60">
|
||||||
|
<span className={`h-1.5 w-1.5 rounded-full ${STATUS_COLOR[saveStatus]}`} aria-hidden="true" />
|
||||||
|
{STATUS_TEXT[saveStatus]}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={logout}
|
||||||
|
className="border border-ivory/20 px-3 py-1.5 text-xs font-light text-ivory/70 transition-colors duration-200 hover:border-copper hover:text-copper"
|
||||||
|
>
|
||||||
|
خروج
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import Swal from 'sweetalert2'
|
||||||
|
import type { SiteData } from '../api'
|
||||||
|
import { useSiteData } from '../api'
|
||||||
|
import { adminApi, getToken, HttpError, setToken } from './adminClient'
|
||||||
|
|
||||||
|
export type SaveStatus = 'idle' | 'saving' | 'error'
|
||||||
|
|
||||||
|
interface AdminContextValue {
|
||||||
|
isAuthenticated: boolean
|
||||||
|
isEditMode: boolean
|
||||||
|
toggleEditMode: () => void
|
||||||
|
login: (username: string, password: string) => Promise<void>
|
||||||
|
logout: () => void
|
||||||
|
saveStatus: SaveStatus
|
||||||
|
/** اجرای یک عملیات ذخیره — وضعیت نوار ادمین را بهروز میکند و خطاها را با دکمه تلاش دوباره نشان میدهد */
|
||||||
|
runSave: (action: () => Promise<void>) => Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdminContext = createContext<AdminContextValue | null>(null)
|
||||||
|
|
||||||
|
export function useAdmin(): AdminContextValue {
|
||||||
|
const ctx = useContext(AdminContext)
|
||||||
|
if (!ctx) throw new Error('useAdmin باید داخل AdminProvider استفاده شود')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSaveError(error: unknown): void {
|
||||||
|
const message =
|
||||||
|
error instanceof HttpError ? error.message : 'ارتباط با سرور برقرار نشد — اتصال اینترنت را بررسی کنید'
|
||||||
|
Swal.fire({
|
||||||
|
icon: 'error',
|
||||||
|
title: 'ذخیره نشد',
|
||||||
|
text: message,
|
||||||
|
background: '#1e1a16',
|
||||||
|
color: '#F5F3EF',
|
||||||
|
iconColor: '#d5874c',
|
||||||
|
confirmButtonText: 'باشه',
|
||||||
|
timer: 4000,
|
||||||
|
timerProgressBar: true,
|
||||||
|
buttonsStyling: false,
|
||||||
|
customClass: {
|
||||||
|
popup: 'font-pinar border border-ivory/10 rounded-none',
|
||||||
|
title: 'font-pinar font-bold text-lg text-ivory',
|
||||||
|
htmlContainer: 'font-pinar font-light text-sm text-ivory/70',
|
||||||
|
confirmButton: 'bg-copper text-char font-bold px-8 py-2.5',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(() => !!getToken())
|
||||||
|
const [isEditMode, setIsEditMode] = useState<boolean>(
|
||||||
|
() => sessionStorage.getItem('aramesh_edit_mode') === '1',
|
||||||
|
)
|
||||||
|
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
|
||||||
|
const { reload } = useSiteData()
|
||||||
|
|
||||||
|
const toggleEditMode = useCallback(() => {
|
||||||
|
setIsEditMode((v) => {
|
||||||
|
sessionStorage.setItem('aramesh_edit_mode', v ? '0' : '1')
|
||||||
|
return !v
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const login = useCallback(async (username: string, password: string) => {
|
||||||
|
await adminApi.login(username, password)
|
||||||
|
setIsAuthenticated(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
setToken(null)
|
||||||
|
setIsAuthenticated(false)
|
||||||
|
setIsEditMode(false)
|
||||||
|
sessionStorage.removeItem('aramesh_edit_mode')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const runSave = useCallback(
|
||||||
|
async (action: () => Promise<void>): Promise<boolean> => {
|
||||||
|
setSaveStatus('saving')
|
||||||
|
try {
|
||||||
|
await action()
|
||||||
|
setSaveStatus('idle')
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
setSaveStatus('error')
|
||||||
|
showSaveError(error)
|
||||||
|
if (error instanceof HttpError && error.status === 401) {
|
||||||
|
logout()
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[logout, reload],
|
||||||
|
)
|
||||||
|
|
||||||
|
// در حالت ویرایش، کلیک روی لینکها مسیریابی نمیکند تا ویرایش متن ممکن باشد
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEditMode) return
|
||||||
|
const onClick = (e: MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement | null
|
||||||
|
if (target?.closest('a')) e.preventDefault()
|
||||||
|
}
|
||||||
|
document.addEventListener('click', onClick, true)
|
||||||
|
return () => document.removeEventListener('click', onClick, true)
|
||||||
|
}, [isEditMode])
|
||||||
|
|
||||||
|
// در حین ذخیرهسازی، از بستهشدن تصادفی صفحه جلوگیری میشود
|
||||||
|
useEffect(() => {
|
||||||
|
if (saveStatus !== 'saving') return
|
||||||
|
const guard = (e: BeforeUnloadEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
window.addEventListener('beforeunload', guard)
|
||||||
|
return () => window.removeEventListener('beforeunload', guard)
|
||||||
|
}, [saveStatus])
|
||||||
|
|
||||||
|
const value = useMemo<AdminContextValue>(
|
||||||
|
() => ({
|
||||||
|
isAuthenticated,
|
||||||
|
isEditMode,
|
||||||
|
toggleEditMode,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
saveStatus,
|
||||||
|
runSave,
|
||||||
|
}),
|
||||||
|
[isAuthenticated, isEditMode, toggleEditMode, login, logout, saveStatus, runSave],
|
||||||
|
)
|
||||||
|
|
||||||
|
return <AdminContext.Provider value={value}>{children}</AdminContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* پرکاربردترین کمکساز ویرایش: یک فیلد واحد را PATCH میکند و پس از موفقیت
|
||||||
|
* همان تغییر را بهصورت خوشبینانه در درخت داده سایت اعمال میکند.
|
||||||
|
*/
|
||||||
|
export function useFieldSaver() {
|
||||||
|
const { runSave } = useAdmin()
|
||||||
|
const { updateSite } = useSiteData()
|
||||||
|
|
||||||
|
return useCallback(
|
||||||
|
(
|
||||||
|
resource: string,
|
||||||
|
id: number,
|
||||||
|
changes: Record<string, unknown>,
|
||||||
|
apply: (data: SiteData) => void,
|
||||||
|
): Promise<boolean> =>
|
||||||
|
runSave(async () => {
|
||||||
|
await adminApi.patch(resource, id, changes)
|
||||||
|
updateSite(apply)
|
||||||
|
}),
|
||||||
|
[runSave, updateSite],
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import type { FormEvent } from 'react'
|
||||||
|
import { Navigate, useNavigate } from 'react-router-dom'
|
||||||
|
import { useAdmin } from './AdminContext'
|
||||||
|
|
||||||
|
/** صفحه ورود ادمین — مسیر پنهان /admin/login */
|
||||||
|
export function AdminLoginPage() {
|
||||||
|
const { isAuthenticated, login } = useAdmin()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
if (isAuthenticated) return <Navigate to="/" replace />
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (submitting) return
|
||||||
|
setError('')
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
await login(username.trim(), password)
|
||||||
|
navigate('/')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'ورود ناموفق بود')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="flex min-h-screen flex-col items-center justify-center bg-ink px-6" dir="rtl">
|
||||||
|
<div className="w-full max-w-sm border border-ivory/10 bg-char p-10">
|
||||||
|
<h1 className="text-xl font-bold text-ivory">ورود مدیریت</h1>
|
||||||
|
<p className="mt-3 text-sm font-light leading-7 text-ivory/55">
|
||||||
|
برای فعالسازی حالت ویرایش زنده سایت وارد شوید.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={submit} className="mt-8 grid gap-5" noValidate>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="admin-username" className="block text-sm font-bold text-ivory/90">
|
||||||
|
نام کاربری
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="admin-username"
|
||||||
|
type="text"
|
||||||
|
dir="ltr"
|
||||||
|
autoComplete="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className="mt-2 w-full border border-ivory/20 bg-ink/50 px-4 py-3 text-sm text-ivory transition-colors duration-200 focus:border-copper focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="admin-password" className="block text-sm font-bold text-ivory/90">
|
||||||
|
رمز عبور
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="admin-password"
|
||||||
|
type="password"
|
||||||
|
dir="ltr"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="mt-2 w-full border border-ivory/20 bg-ink/50 px-4 py-3 text-sm text-ivory transition-colors duration-200 focus:border-copper focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="border-r-2 border-red-400/70 pr-3 text-xs font-light leading-6 text-red-300">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="mt-2 bg-copper px-8 py-3 text-sm font-bold text-char transition-transform duration-200 hover:scale-[1.02] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? 'در حال ورود…' : 'ورود'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { ArrowDownIcon, ArrowUpIcon, PencilIcon, PlusIcon, Trash2Icon } from 'lucide-react'
|
||||||
|
import Swal from 'sweetalert2'
|
||||||
|
import { useSiteData } from '../api'
|
||||||
|
import { useAdmin } from './AdminContext'
|
||||||
|
import { adminApi } from './adminClient'
|
||||||
|
import { ENTITIES } from './entities'
|
||||||
|
import { EntityDrawer } from './EntityDrawer'
|
||||||
|
|
||||||
|
type ItemWithId = Record<string, unknown> & { id: number }
|
||||||
|
|
||||||
|
function useEntityList(resource: string): ItemWithId[] {
|
||||||
|
const { data } = useSiteData()
|
||||||
|
const config = ENTITIES[resource]
|
||||||
|
if (!config) throw new Error(`منبع ناشناخته: ${resource}`)
|
||||||
|
return (data?.[config.dataKey as keyof NonNullable<typeof data>] ?? []) as ItemWithId[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function PillButton({
|
||||||
|
onClick,
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
danger = false,
|
||||||
|
}: {
|
||||||
|
onClick: () => void
|
||||||
|
label: string
|
||||||
|
children: ReactNode
|
||||||
|
danger?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
onClick()
|
||||||
|
}}
|
||||||
|
className={`flex h-7 w-7 items-center justify-center border backdrop-blur-sm transition-colors duration-150 ${
|
||||||
|
danger
|
||||||
|
? 'border-red-400/40 bg-ink/90 text-red-300 hover:bg-red-500/20'
|
||||||
|
: 'border-copper/50 bg-ink/90 text-copper hover:bg-copper hover:text-char'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* بستهبندی آیتمهای مجموعهها — فقط در حالت ویرایش ادمین، دکمههای شناور
|
||||||
|
* (ویرایش / بالا / پایین / حذف) روی آیتم نشان میدهد؛ در حالت عادی خروجی یکسان است.
|
||||||
|
*/
|
||||||
|
export function EditableItem({
|
||||||
|
resource,
|
||||||
|
item,
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
resource: string
|
||||||
|
item: ItemWithId
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const { isAuthenticated, isEditMode, runSave } = useAdmin()
|
||||||
|
const { reload } = useSiteData()
|
||||||
|
const config = ENTITIES[resource]
|
||||||
|
const list = useEntityList(resource)
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||||
|
|
||||||
|
if (!config) return <>{children}</>
|
||||||
|
if (!isAuthenticated || !isEditMode) return <>{children}</>
|
||||||
|
|
||||||
|
const index = list.findIndex((x) => x.id === item.id)
|
||||||
|
|
||||||
|
const move = (delta: number) => {
|
||||||
|
const target = index + delta
|
||||||
|
if (target < 0 || target >= list.length) return
|
||||||
|
const ids = list.map((x) => x.id)
|
||||||
|
;[ids[index], ids[target]] = [ids[target], ids[index]]
|
||||||
|
void runSave(async () => {
|
||||||
|
await adminApi.reorder(config.resource, ids)
|
||||||
|
reload()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
const result = await Swal.fire({
|
||||||
|
icon: 'warning',
|
||||||
|
title: 'حذف رکورد',
|
||||||
|
text: `«${String(item[config.titleKey] ?? '')}» برای همیشه حذف شود؟`,
|
||||||
|
background: '#1e1a16',
|
||||||
|
color: '#F5F3EF',
|
||||||
|
iconColor: '#d5874c',
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonText: 'حذف',
|
||||||
|
cancelButtonText: 'انصراف',
|
||||||
|
buttonsStyling: false,
|
||||||
|
customClass: {
|
||||||
|
popup: 'font-pinar border border-ivory/10',
|
||||||
|
confirmButton: 'bg-red-500 text-white font-bold px-8 py-2.5 mx-2',
|
||||||
|
cancelButton: 'border border-ivory/30 text-ivory font-light px-8 py-2.5 mx-2',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!result.isConfirmed) return
|
||||||
|
void runSave(async () => {
|
||||||
|
await adminApi.remove(config.resource, item.id)
|
||||||
|
reload()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative group/editable ${className}`}>
|
||||||
|
<div className="absolute left-2 top-2 z-30 flex flex-col gap-1.5 opacity-0 transition-opacity duration-150 group-hover/editable:opacity-100">
|
||||||
|
<PillButton onClick={() => setDrawerOpen(true)} label="ویرایش">
|
||||||
|
<PencilIcon className="h-3.5 w-3.5" />
|
||||||
|
</PillButton>
|
||||||
|
{index > 0 && (
|
||||||
|
<PillButton onClick={() => move(-1)} label="انتقال به قبل">
|
||||||
|
<ArrowUpIcon className="h-3.5 w-3.5" />
|
||||||
|
</PillButton>
|
||||||
|
)}
|
||||||
|
{index < list.length - 1 && (
|
||||||
|
<PillButton onClick={() => move(1)} label="انتقال به بعد">
|
||||||
|
<ArrowDownIcon className="h-3.5 w-3.5" />
|
||||||
|
</PillButton>
|
||||||
|
)}
|
||||||
|
<PillButton onClick={remove} label="حذف" danger>
|
||||||
|
<Trash2Icon className="h-3.5 w-3.5" />
|
||||||
|
</PillButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{drawerOpen && (
|
||||||
|
<EntityDrawer config={config} item={item} deletable onClose={() => setDrawerOpen(false)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** دکمه افزودن آیتم جدید به یک مجموعه — فقط در حالت ویرایش */
|
||||||
|
export function AddItemButton({ resource }: { resource: string }) {
|
||||||
|
const { isAuthenticated, isEditMode, runSave } = useAdmin()
|
||||||
|
const { reload } = useSiteData()
|
||||||
|
const config = ENTITIES[resource]
|
||||||
|
const [newItem, setNewItem] = useState<ItemWithId | null>(null)
|
||||||
|
|
||||||
|
if (!config || !isAuthenticated || !isEditMode) return null
|
||||||
|
|
||||||
|
const create = () => {
|
||||||
|
void runSave(async () => {
|
||||||
|
const created = await adminApi.create<ItemWithId>(config.resource, config.defaults)
|
||||||
|
reload()
|
||||||
|
setNewItem(created)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={create}
|
||||||
|
className="flex min-h-40 w-full flex-col items-center justify-center gap-2 border border-dashed border-copper/40 bg-copper/5 text-copper transition-colors duration-200 hover:bg-copper/10"
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-6 w-6" />
|
||||||
|
<span className="text-sm font-light">افزودن {config.title}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{newItem && (
|
||||||
|
<EntityDrawer config={config} item={newItem} deletable onClose={() => setNewItem(null)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** دکمه شناور بازکردن کشوی یک رکورد یگانه (برند، هیرو، فرم تماس و...) — فقط در حالت ویرایش */
|
||||||
|
export function SingletonPill({
|
||||||
|
resource,
|
||||||
|
label,
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
resource: string
|
||||||
|
label: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const { isAuthenticated, isEditMode } = useAdmin()
|
||||||
|
const config = ENTITIES[resource]
|
||||||
|
const { data } = useSiteData()
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||||
|
|
||||||
|
if (!config || !isAuthenticated || !isEditMode) return null
|
||||||
|
const item = data?.[config.dataKey as keyof NonNullable<typeof data>] as ItemWithId | undefined
|
||||||
|
if (!item) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className={className}>
|
||||||
|
<PillButton onClick={() => setDrawerOpen(true)} label={label}>
|
||||||
|
<PencilIcon className="h-3.5 w-3.5" />
|
||||||
|
</PillButton>
|
||||||
|
</span>
|
||||||
|
{drawerOpen && <EntityDrawer config={config} item={item} onClose={() => setDrawerOpen(false)} />}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
|
import { PlusIcon, Trash2Icon, XIcon } from 'lucide-react'
|
||||||
|
import Swal from 'sweetalert2'
|
||||||
|
import { useSiteData } from '../api'
|
||||||
|
import { useAdmin, useFieldSaver } from './AdminContext'
|
||||||
|
import { adminApi } from './adminClient'
|
||||||
|
import type { EntityConfig, FieldConfig } from './entities'
|
||||||
|
import { ImageField } from '../components/admin/ImageField'
|
||||||
|
|
||||||
|
type EntityDrawerProps = {
|
||||||
|
config: EntityConfig
|
||||||
|
/** رکورد یگانه یا آیتم مجموعه — باید id داشته باشد */
|
||||||
|
item: Record<string, unknown> & { id: number }
|
||||||
|
onClose: () => void
|
||||||
|
/** فقط برای مجموعهها: حذف آیتم */
|
||||||
|
deletable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EntityDrawer({ config, item, onClose, deletable = false }: EntityDrawerProps) {
|
||||||
|
const save = useFieldSaver()
|
||||||
|
const { runSave } = useAdmin()
|
||||||
|
const { reload } = useSiteData()
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
|
const saveField = (name: string, value: unknown) =>
|
||||||
|
save(config.resource, item.id, { [name]: value }, (d) => {
|
||||||
|
const target = d[config.dataKey as keyof typeof d] as unknown
|
||||||
|
const node =
|
||||||
|
config.kind === 'singleton'
|
||||||
|
? (target as Record<string, unknown>)
|
||||||
|
: (target as { id: number }[]).find((x) => x.id === item.id)
|
||||||
|
if (node) (node as Record<string, unknown>)[name] = value
|
||||||
|
})
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
const result = await Swal.fire({
|
||||||
|
icon: 'warning',
|
||||||
|
title: 'حذف رکورد',
|
||||||
|
text: `«${String(item[config.titleKey] ?? '')}» برای همیشه حذف شود؟`,
|
||||||
|
background: '#1e1a16',
|
||||||
|
color: '#F5F3EF',
|
||||||
|
iconColor: '#d5874c',
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonText: 'حذف',
|
||||||
|
cancelButtonText: 'انصراف',
|
||||||
|
buttonsStyling: false,
|
||||||
|
customClass: {
|
||||||
|
popup: 'font-pinar border border-ivory/10',
|
||||||
|
confirmButton: 'bg-red-500 text-white font-bold px-8 py-2.5 mx-2',
|
||||||
|
cancelButton: 'border border-ivory/30 text-ivory font-light px-8 py-2.5 mx-2',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!result.isConfirmed) return
|
||||||
|
setDeleting(true)
|
||||||
|
await runSave(async () => {
|
||||||
|
await adminApi.remove(config.resource, item.id)
|
||||||
|
reload()
|
||||||
|
onClose()
|
||||||
|
})
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
key="backdrop"
|
||||||
|
className="fixed inset-0 z-[95] bg-char/70 backdrop-blur-sm"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<motion.aside
|
||||||
|
key="drawer"
|
||||||
|
dir="rtl"
|
||||||
|
role="dialog"
|
||||||
|
aria-label={`ویرایش ${config.title}`}
|
||||||
|
className="fixed inset-y-0 left-0 z-[96] flex w-full max-w-md flex-col border-e border-ivory/10 bg-ink shadow-2xl"
|
||||||
|
initial={{ x: '-100%' }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: '-100%' }}
|
||||||
|
transition={{ duration: 0.25, ease: [0.23, 1, 0.32, 1] }}
|
||||||
|
>
|
||||||
|
<header className="flex items-center justify-between border-b border-ivory/10 px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-light text-copper">
|
||||||
|
{config.kind === 'singleton' ? 'تنظیمات بخش' : 'ویرایش رکورد'}
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-1 text-lg font-bold text-ivory">{config.title}</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="بستن"
|
||||||
|
className="flex h-9 w-9 items-center justify-center border border-ivory/20 text-ivory/70 transition-colors hover:border-copper hover:text-copper"
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-6">
|
||||||
|
<div className="grid gap-6">
|
||||||
|
{config.fields.map((field) => (
|
||||||
|
<FieldControl
|
||||||
|
key={field.name}
|
||||||
|
field={field}
|
||||||
|
value={item[field.name]}
|
||||||
|
onSave={(value) => saveField(field.name, value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{deletable && (
|
||||||
|
<footer className="border-t border-ivory/10 px-6 py-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={remove}
|
||||||
|
disabled={deleting}
|
||||||
|
className="flex items-center gap-2 border border-red-400/40 px-4 py-2 text-xs font-light text-red-300 transition-colors hover:bg-red-500/10 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Trash2Icon className="h-3.5 w-3.5" />
|
||||||
|
حذف این رکورد
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
)}
|
||||||
|
</motion.aside>
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldControl({
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
field: FieldConfig
|
||||||
|
value: unknown
|
||||||
|
onSave: (value: unknown) => Promise<boolean>
|
||||||
|
}) {
|
||||||
|
const [text, setText] = useState<string>(String(value ?? ''))
|
||||||
|
|
||||||
|
const commitText = async () => {
|
||||||
|
const next = field.type === 'number' ? Number(text) || 0 : text
|
||||||
|
if (String(value ?? '') === String(next)) return
|
||||||
|
await onSave(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'image') {
|
||||||
|
return <ImageField label={field.label} value={String(value ?? '')} onSave={(url) => onSave(url)} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'list') {
|
||||||
|
return <ListField field={field} value={value} onSave={onSave} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'textarea') {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||||
|
<textarea
|
||||||
|
dir="auto"
|
||||||
|
rows={4}
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onBlur={commitText}
|
||||||
|
className="mt-2 w-full resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||||
|
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
||||||
|
<input
|
||||||
|
dir={field.type === 'number' ? 'ltr' : 'auto'}
|
||||||
|
type={field.type === 'number' ? 'number' : 'text'}
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onBlur={commitText}
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ویرایش آرایه خطی (پاراگرافها، گالری، نکات) — هر تغییر ذخیره میشود */
|
||||||
|
function ListField({
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
field: FieldConfig
|
||||||
|
value: unknown
|
||||||
|
onSave: (value: unknown) => Promise<boolean>
|
||||||
|
}) {
|
||||||
|
const [lines, setLines] = useState<string[]>(Array.isArray(value) ? (value as string[]) : [])
|
||||||
|
|
||||||
|
const update = (next: string[]) => {
|
||||||
|
setLines(next)
|
||||||
|
void onSave(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||||
|
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
||||||
|
<div className="mt-2 grid gap-2">
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2">
|
||||||
|
<textarea
|
||||||
|
dir="auto"
|
||||||
|
rows={line.length > 60 ? 3 : 1}
|
||||||
|
value={line}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...lines]
|
||||||
|
next[i] = e.target.value
|
||||||
|
setLines(next)
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
if (line !== (value as string[])[i]) update(lines)
|
||||||
|
}}
|
||||||
|
className="flex-1 resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="حذف خط"
|
||||||
|
onClick={() => update(lines.filter((_, j) => j !== i))}
|
||||||
|
className="mt-1 text-ivory/40 transition-colors hover:text-red-300"
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => update([...lines, ''])}
|
||||||
|
className="flex w-fit items-center gap-1.5 border border-dashed border-ivory/25 px-3 py-1.5 text-xs font-light text-ivory/60 transition-colors hover:border-copper hover:text-copper"
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-3 w-3" />
|
||||||
|
افزودن خط
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// لایه تماس ادمین — همه درخواستها با توکن Bearer و مدیریت خطای یکسان
|
||||||
|
import { API_URL } from '../config'
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'aramesh_admin_token'
|
||||||
|
|
||||||
|
export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY)
|
||||||
|
export const setToken = (token: string | null): void => {
|
||||||
|
if (token) localStorage.setItem(TOKEN_KEY, token)
|
||||||
|
else localStorage.removeItem(TOKEN_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HttpError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const token = getToken()
|
||||||
|
const isFormData = options.body instanceof FormData
|
||||||
|
const res = await fetch(`${API_URL}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...(options.headers ?? {}),
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
...(options.body && !isFormData ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
setToken(null)
|
||||||
|
throw new HttpError(401, 'نشست شما منقضی شده — دوباره وارد شوید')
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = (await res.json().catch(() => null)) as { detail?: unknown } | null
|
||||||
|
const detail =
|
||||||
|
typeof data?.detail === 'string'
|
||||||
|
? data.detail
|
||||||
|
: res.status === 409
|
||||||
|
? 'رکوردی با این مقدار تکراری وجود دارد'
|
||||||
|
: 'خطا در ذخیرهسازی — دوباره تلاش کنید'
|
||||||
|
throw new HttpError(res.status, detail)
|
||||||
|
}
|
||||||
|
return res.status === 204 ? (null as T) : ((await res.json()) as T)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminApi = {
|
||||||
|
login: async (username: string, password: string): Promise<string> => {
|
||||||
|
const res = await fetch(`${API_URL}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = (await res.json().catch(() => null)) as { detail?: unknown } | null
|
||||||
|
throw new HttpError(
|
||||||
|
res.status,
|
||||||
|
typeof data?.detail === 'string' ? data.detail : 'ورود ناموفق بود',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { access_token: string }
|
||||||
|
setToken(data.access_token)
|
||||||
|
return data.access_token
|
||||||
|
},
|
||||||
|
|
||||||
|
patch: <T = unknown>(resource: string, id: number, partial: Record<string, unknown>) =>
|
||||||
|
authFetch<T>(`/api/admin/${resource}/${id}`, { method: 'PATCH', body: JSON.stringify(partial) }),
|
||||||
|
|
||||||
|
create: <T = unknown>(resource: string, data: Record<string, unknown>) =>
|
||||||
|
authFetch<T>(`/api/admin/${resource}`, { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
|
||||||
|
remove: (resource: string, id: number) =>
|
||||||
|
authFetch<null>(`/api/admin/${resource}/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
reorder: <T = unknown>(resource: string, ids: number[]) =>
|
||||||
|
authFetch<T[]>(`/api/admin/${resource}/reorder`, { method: 'POST', body: JSON.stringify({ ids }) }),
|
||||||
|
|
||||||
|
upload: async (file: File): Promise<{ url: string; key: string }> => {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
return authFetch('/api/admin/upload', { method: 'POST', body: fd })
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import type { SiteData } from '../api'
|
||||||
|
|
||||||
|
export type FieldType = 'text' | 'textarea' | 'number' | 'image' | 'list'
|
||||||
|
|
||||||
|
export type FieldConfig = {
|
||||||
|
name: string
|
||||||
|
label: string
|
||||||
|
type: FieldType
|
||||||
|
hint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EntityConfig = {
|
||||||
|
/** مسیر منبع در پنل مدیریت، مثل services یا why-us */
|
||||||
|
resource: string
|
||||||
|
/** کلید همان منبع در پاسخ GET /api/site */
|
||||||
|
dataKey: string
|
||||||
|
title: string
|
||||||
|
/** مجموعه یا رکورد یگانه */
|
||||||
|
kind: 'collection' | 'singleton'
|
||||||
|
/** فیلدی که عنوان آیتم را نشان میدهد */
|
||||||
|
titleKey: string
|
||||||
|
fields: FieldConfig[]
|
||||||
|
/** مقادیر پیشفرض برای «افزودن» */
|
||||||
|
defaults: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
const F = (name: string, label: string, type: FieldType = 'text', hint?: string): FieldConfig => ({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
type,
|
||||||
|
hint,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** پیکربندی همه منابع قابل ویرایش — هم مجموعهها و هم رکوردهای یگانه */
|
||||||
|
export const ENTITIES: Record<string, EntityConfig> = {
|
||||||
|
services: {
|
||||||
|
resource: 'services', dataKey: 'services', title: 'خدمات', kind: 'collection',
|
||||||
|
titleKey: 'title',
|
||||||
|
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('icon', 'آیکون', 'text', 'draft / tower / mall / villa / renew / manage / invest / landscape')],
|
||||||
|
defaults: { title: 'خدمت جدید', desc: '', icon: 'draft' },
|
||||||
|
},
|
||||||
|
projects: {
|
||||||
|
resource: 'projects', dataKey: 'projects', title: 'پروژهها', kind: 'collection',
|
||||||
|
titleKey: 'title',
|
||||||
|
fields: [
|
||||||
|
F('title', 'عنوان'), F('slug', 'نشانی (slug)'), F('location', 'موقعیت'),
|
||||||
|
F('year', 'سال'), F('status', 'وضعیت', 'text', 'done یا progress'),
|
||||||
|
F('image', 'تصویر شاخص', 'image'), F('gallery', 'گالری (هر خط یک URL)', 'list'),
|
||||||
|
F('area', 'زیربنا'), F('floors', 'طبقات'), F('units', 'واحدها'), F('client', 'کارفرما'),
|
||||||
|
F('summary', 'خلاصه', 'textarea'), F('body', 'متن پروژه (هر پاراگراف یک خط)', 'list'),
|
||||||
|
],
|
||||||
|
defaults: { title: 'پروژه جدید', slug: `project-${Date.now()}`, location: '', year: '', status: 'done', image: '', gallery: [], area: '', floors: '', units: '', client: '', summary: '', body: [] },
|
||||||
|
},
|
||||||
|
articles: {
|
||||||
|
resource: 'articles', dataKey: 'articles', title: 'مقالات', kind: 'collection',
|
||||||
|
titleKey: 'title',
|
||||||
|
fields: [
|
||||||
|
F('title', 'عنوان'), F('slug', 'نشانی (slug)'), F('date', 'تاریخ'), F('category', 'دسته'),
|
||||||
|
F('image', 'تصویر شاخص', 'image'), F('read', 'زمان مطالعه'), F('excerpt', 'چکیده', 'textarea'),
|
||||||
|
F('body', 'متن مقاله (هر پاراگراف یک خط)', 'list'),
|
||||||
|
],
|
||||||
|
defaults: { title: 'مقاله جدید', slug: `article-${Date.now()}`, date: '', category: '', image: '', read: '', excerpt: '', body: [] },
|
||||||
|
},
|
||||||
|
testimonials: {
|
||||||
|
resource: 'testimonials', dataKey: 'testimonials', title: 'نظرات مشتریان', kind: 'collection',
|
||||||
|
titleKey: 'name',
|
||||||
|
fields: [F('name', 'نام'), F('role', 'سمت'), F('quote', 'متن نظر', 'textarea')],
|
||||||
|
defaults: { name: 'نظر جدید', role: '', quote: '' },
|
||||||
|
},
|
||||||
|
faqs: {
|
||||||
|
resource: 'faqs', dataKey: 'faqs', title: 'سوالات متداول', kind: 'collection',
|
||||||
|
titleKey: 'question',
|
||||||
|
fields: [F('question', 'سوال'), F('answer', 'پاسخ', 'textarea')],
|
||||||
|
defaults: { question: 'سوال جدید', answer: '' },
|
||||||
|
},
|
||||||
|
team: {
|
||||||
|
resource: 'team', dataKey: 'team', title: 'تیم', kind: 'collection',
|
||||||
|
titleKey: 'name',
|
||||||
|
fields: [F('name', 'نام'), F('role', 'سمت'), F('bio', 'معرفی', 'textarea')],
|
||||||
|
defaults: { name: 'عضو جدید', role: '', bio: '' },
|
||||||
|
},
|
||||||
|
timeline: {
|
||||||
|
resource: 'timeline', dataKey: 'timeline', title: 'تایملاین', kind: 'collection',
|
||||||
|
titleKey: 'title',
|
||||||
|
fields: [F('year', 'سال'), F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('image', 'تصویر', 'image')],
|
||||||
|
defaults: { year: '۱۴۰۰', title: 'رویداد جدید', desc: '', image: '' },
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
resource: 'stats', dataKey: 'stats', title: 'آمار', kind: 'collection',
|
||||||
|
titleKey: 'label',
|
||||||
|
fields: [F('label', 'برچسب'), F('value', 'عدد', 'number'), F('suffix', 'پسوند (مثل + یا هزار)')],
|
||||||
|
defaults: { label: 'شاخص جدید', value: 0, suffix: '' },
|
||||||
|
},
|
||||||
|
why_us: {
|
||||||
|
resource: 'why-us', dataKey: 'why_us', title: 'چرا آرامش', kind: 'collection',
|
||||||
|
titleKey: 'title',
|
||||||
|
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea')],
|
||||||
|
defaults: { title: 'مورد جدید', desc: '' },
|
||||||
|
},
|
||||||
|
investment_models: {
|
||||||
|
resource: 'investment-models', dataKey: 'investment_models', title: 'مدلهای سرمایهگذاری', kind: 'collection',
|
||||||
|
titleKey: 'title',
|
||||||
|
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('points', 'نکات (هر خط یک مورد)', 'list')],
|
||||||
|
defaults: { title: 'مدل جدید', desc: '', points: [] },
|
||||||
|
},
|
||||||
|
process_steps: {
|
||||||
|
resource: 'process-steps', dataKey: 'process_steps', title: 'مراحل فرآیند کار', kind: 'collection',
|
||||||
|
titleKey: 'title',
|
||||||
|
fields: [F('step', 'شماره مرحله'), F('title', 'عنوان'), F('desc', 'توضیح', 'textarea')],
|
||||||
|
defaults: { step: '۰۱', title: 'مرحله جدید', desc: '' },
|
||||||
|
},
|
||||||
|
offices: {
|
||||||
|
resource: 'offices', dataKey: 'offices', title: 'دفاتر', kind: 'collection',
|
||||||
|
titleKey: 'city',
|
||||||
|
fields: [F('city', 'شهر'), F('address', 'نشانی', 'textarea'), F('hours', 'ساعات کاری')],
|
||||||
|
defaults: { city: 'شهر جدید', address: '', hours: '' },
|
||||||
|
},
|
||||||
|
gallery: {
|
||||||
|
resource: 'gallery', dataKey: 'gallery', title: 'گالری تصاویر', kind: 'collection',
|
||||||
|
titleKey: 'alt',
|
||||||
|
fields: [F('src', 'تصویر', 'image'), F('alt', 'متن جایگزین')],
|
||||||
|
defaults: { src: '', alt: 'تصویر گالری' },
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
resource: 'nav', dataKey: 'nav', title: 'منوی ناوبری', kind: 'collection',
|
||||||
|
titleKey: 'label',
|
||||||
|
fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)')],
|
||||||
|
defaults: { label: 'آیتم جدید', href: '/' },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- رکوردهای یگانه ----------
|
||||||
|
brand: {
|
||||||
|
resource: 'brand', dataKey: 'brand', title: 'برند و تماس', kind: 'singleton',
|
||||||
|
titleKey: 'name',
|
||||||
|
fields: [F('name', 'نام برند'), F('logo', 'لوگو', 'image'), F('hero', 'تصویر هیرو', 'image'), F('contact_bg', 'پسزمینه بخش تماس', 'image'), F('phone', 'تلفن نمایشی'), F('phone_href', 'لینک تلفن'), F('email', 'ایمیل'), F('address', 'نشانی', 'textarea')],
|
||||||
|
defaults: {},
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
resource: 'hero', dataKey: 'hero', title: 'تنظیمات هیرو', kind: 'singleton',
|
||||||
|
titleKey: 'title',
|
||||||
|
fields: [F('title_highlight', 'واژه مسیِ تیتر'), F('badge_value', 'عدد کارت کنار'), F('badge_label', 'متن کارت کنار', 'textarea'), F('cta_primary_label', 'متن دکمه اصلی'), F('cta_primary_href', 'لینک دکمه اصلی'), F('cta_secondary_label', 'متن دکمه دوم'), F('cta_secondary_href', 'لینک دکمه دوم'), F('scroll_label', 'متن اسکرول'), F('kicker', 'متن بالای تیتر', 'textarea')],
|
||||||
|
defaults: {},
|
||||||
|
},
|
||||||
|
story: {
|
||||||
|
resource: 'story', dataKey: 'story', title: 'تصاویر بخش درباره ما', kind: 'singleton',
|
||||||
|
titleKey: 'title',
|
||||||
|
fields: [F('quote', 'نقلقول مدیرعامل', 'textarea'), F('quote_author', 'استناد نقلقول'), F('stat1_value', 'آمار ۱ — عدد'), F('stat1_label', 'آمار ۱ — برچسب'), F('stat2_value', 'آمار ۲ — عدد'), F('stat2_label', 'آمار ۲ — برچسب'), F('link_label', 'متن دکمه'), F('link_href', 'لینک دکمه')],
|
||||||
|
defaults: {},
|
||||||
|
},
|
||||||
|
before_after: {
|
||||||
|
resource: 'before-after', dataKey: 'before_after', title: 'بخش قبل/بعد', kind: 'singleton',
|
||||||
|
titleKey: 'title',
|
||||||
|
fields: [F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'), F('before', 'تصویر قبل', 'image'), F('after', 'تصویر بعد', 'image'), F('before_label', 'برچسب قبل'), F('after_label', 'برچسب بعد')],
|
||||||
|
defaults: {},
|
||||||
|
},
|
||||||
|
contact_section: {
|
||||||
|
resource: 'contact-section', dataKey: 'contact_section', title: 'فرم تماس', kind: 'singleton',
|
||||||
|
titleKey: 'title',
|
||||||
|
fields: [
|
||||||
|
F('label', 'برچسب بالا'), F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'),
|
||||||
|
F('address_label', 'برچسب نشانی'), F('phone_label', 'برچسب تلفن'), F('email_label', 'برچسب ایمیل'),
|
||||||
|
F('name_label', 'برچسب نام'), F('name_placeholder', 'جاینگهدار نام'),
|
||||||
|
F('phone_field_label', 'برچسب تلفن (فرم)'), F('phone_placeholder', 'جاینگهدار تلفن'),
|
||||||
|
F('note_label', 'برچسب توضیحات'), F('note_optional_label', 'برچسب اختیاری'), F('note_placeholder', 'جاینگهدار توضیحات'),
|
||||||
|
F('submit_label', 'متن دکمه ارسال'), F('sending_label', 'متن در حال ارسال'),
|
||||||
|
F('success_title', 'عنوان پیام موفقیت'), F('success_text', 'متن پیام موفقیت', 'textarea'), F('success_button_label', 'دکمه پیام موفقیت'),
|
||||||
|
],
|
||||||
|
defaults: {},
|
||||||
|
},
|
||||||
|
newsletter: {
|
||||||
|
resource: 'newsletter', dataKey: 'newsletter', title: 'خبرنامه', kind: 'singleton',
|
||||||
|
titleKey: 'title',
|
||||||
|
fields: [F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'), F('placeholder', 'جاینگهدار ایمیل'), F('button_label', 'متن دکمه'), F('sending_label', 'متن در حال ارسال'), F('success_text', 'پیام موفقیت')],
|
||||||
|
defaults: {},
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
resource: 'footer', dataKey: 'footer', title: 'فوتر', kind: 'singleton',
|
||||||
|
titleKey: 'pages_heading',
|
||||||
|
fields: [F('about_text', 'متن معرفی', 'textarea'), F('pages_heading', 'تیتر ستون صفحات'), F('services_heading', 'تیتر ستون خدمات'), F('contact_heading', 'تیتر ستون تماس'), F('copyright', 'متن کپیرایت'), F('tagline', 'شعار پایین')],
|
||||||
|
defaults: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** دسترسی به آرایه/شیء منبع از درخت داده سایت */
|
||||||
|
export function getEntityData(data: SiteData, config: EntityConfig): Record<string, unknown> & { id: number }[] | (Record<string, unknown> & { id: number }) {
|
||||||
|
return data[config.dataKey as keyof SiteData] as never
|
||||||
|
}
|
||||||
+22
-2
@@ -159,9 +159,16 @@ interface SiteContextValue {
|
|||||||
data: SiteData | null
|
data: SiteData | null
|
||||||
error: boolean
|
error: boolean
|
||||||
reload: () => void
|
reload: () => void
|
||||||
|
/** بهروزرسانی خوشبینانه درخت داده — پس از موفقیت PATCH استفاده میشود */
|
||||||
|
updateSite: (mutate: (data: SiteData) => void) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const SiteContext = createContext<SiteContextValue>({ data: null, error: false, reload: () => {} })
|
const SiteContext = createContext<SiteContextValue>({
|
||||||
|
data: null,
|
||||||
|
error: false,
|
||||||
|
reload: () => {},
|
||||||
|
updateSite: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
export function SiteProvider({ children }: { children: ReactNode }) {
|
export function SiteProvider({ children }: { children: ReactNode }) {
|
||||||
const [data, setData] = useState<SiteData | null>(null)
|
const [data, setData] = useState<SiteData | null>(null)
|
||||||
@@ -197,7 +204,20 @@ export function SiteProvider({ children }: { children: ReactNode }) {
|
|||||||
load()
|
load()
|
||||||
}, [load])
|
}, [load])
|
||||||
|
|
||||||
return <SiteContext.Provider value={{ data, error, reload }}>{children}</SiteContext.Provider>
|
const updateSite = useCallback((mutate: (data: SiteData) => void) => {
|
||||||
|
setData((prev) => {
|
||||||
|
if (!prev) return prev
|
||||||
|
const next = structuredClone(prev)
|
||||||
|
mutate(next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SiteContext.Provider value={{ data, error, reload, updateSite }}>
|
||||||
|
{children}
|
||||||
|
</SiteContext.Provider>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSiteData(): SiteContextValue {
|
export function useSiteData(): SiteContextValue {
|
||||||
|
|||||||
@@ -4,14 +4,28 @@ import Swal from 'sweetalert2'
|
|||||||
import { Reveal } from './Reveal'
|
import { Reveal } from './Reveal'
|
||||||
import { Button } from './Button'
|
import { Button } from './Button'
|
||||||
import { useSite } from '../api'
|
import { useSite } from '../api'
|
||||||
|
import { useFieldSaver } from '../admin/AdminContext'
|
||||||
|
import { InlineEdit } from './admin/InlineEdit'
|
||||||
|
import { SingletonPill } from '../admin/Editable'
|
||||||
import { API_URL } from '../config'
|
import { API_URL } from '../config'
|
||||||
|
|
||||||
export function ContactSection() {
|
export function ContactSection() {
|
||||||
const { brand, contact_section: content } = useSite()
|
const { brand, contact_section: content } = useSite()
|
||||||
|
const save = useFieldSaver()
|
||||||
const [form, setForm] = useState({ name: '', phone: '', note: '' })
|
const [form, setForm] = useState({ name: '', phone: '', note: '' })
|
||||||
const [sent, setSent] = useState(false)
|
const [sent, setSent] = useState(false)
|
||||||
const [sending, setSending] = useState(false)
|
const [sending, setSending] = useState(false)
|
||||||
|
|
||||||
|
const saveContent = (field: string, value: string) =>
|
||||||
|
save('contact-section', content.id, { [field]: value }, (d) => {
|
||||||
|
;(d.contact_section as unknown as Record<string, unknown>)[field] = value
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveBrand = (field: string, value: string) =>
|
||||||
|
save('brand', brand.id, { [field]: value }, (d) => {
|
||||||
|
;(d.brand as unknown as Record<string, unknown>)[field] = value
|
||||||
|
})
|
||||||
|
|
||||||
const showErrorAlert = (title: string, text: string) => {
|
const showErrorAlert = (title: string, text: string) => {
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
icon: 'warning',
|
icon: 'warning',
|
||||||
@@ -93,15 +107,17 @@ export function ContactSection() {
|
|||||||
|
|
||||||
<div className="relative mx-auto max-w-site px-6 lg:px-10">
|
<div className="relative mx-auto max-w-site px-6 lg:px-10">
|
||||||
<div className="mx-auto grid max-w-6xl items-stretch gap-6 lg:grid-cols-12">
|
<div className="mx-auto grid max-w-6xl items-stretch gap-6 lg:grid-cols-12">
|
||||||
|
|
||||||
<div className="border border-ivory/20 bg-char/45 p-8 backdrop-blur-[8px] md:p-10 lg:col-span-5">
|
<div className="border border-ivory/20 bg-char/45 p-8 backdrop-blur-[8px] md:p-10 lg:col-span-5">
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<p className="text-sm font-light text-copper">{content.label}</p>
|
<p className="text-sm font-light text-copper">
|
||||||
|
<InlineEdit value={content.label} onSave={(v) => saveContent('label', v)} />
|
||||||
|
</p>
|
||||||
<h2 id="contact-title" className="mt-5 text-section font-bold text-ivory">
|
<h2 id="contact-title" className="mt-5 text-section font-bold text-ivory">
|
||||||
{content.title}
|
<InlineEdit value={content.title} onSave={(v) => saveContent('title', v)} />
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-6 text-base font-light leading-9 text-ivory/75">
|
<p className="mt-6 text-base font-light leading-9 text-ivory/75">
|
||||||
{content.lead}
|
<InlineEdit multiline value={content.lead} onSave={(v) => saveContent('lead', v)} />
|
||||||
</p>
|
</p>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
@@ -110,33 +126,41 @@ export function ContactSection() {
|
|||||||
<li className="flex items-start gap-4">
|
<li className="flex items-start gap-4">
|
||||||
<MapPinIcon className="mt-1 h-5 w-5 shrink-0 text-copper" />
|
<MapPinIcon className="mt-1 h-5 w-5 shrink-0 text-copper" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-bold text-ivory">{content.address_label}</p>
|
<p className="text-sm font-bold text-ivory">
|
||||||
<p className="mt-1 text-sm font-light leading-7 text-ivory/70">{brand.address}</p>
|
<InlineEdit value={content.address_label} onSave={(v) => saveContent('address_label', v)} />
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm font-light leading-7 text-ivory/70">
|
||||||
|
<InlineEdit value={brand.address} onSave={(v) => saveBrand('address', v)} />
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start gap-4">
|
<li className="flex items-start gap-4">
|
||||||
<PhoneIcon className="mt-1 h-5 w-5 shrink-0 text-copper" />
|
<PhoneIcon className="mt-1 h-5 w-5 shrink-0 text-copper" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-bold text-ivory">{content.phone_label}</p>
|
<p className="text-sm font-bold text-ivory">
|
||||||
|
<InlineEdit value={content.phone_label} onSave={(v) => saveContent('phone_label', v)} />
|
||||||
|
</p>
|
||||||
<a
|
<a
|
||||||
href={brand.phone_href}
|
href={brand.phone_href}
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
|
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
|
||||||
>
|
>
|
||||||
{brand.phone}
|
<InlineEdit value={brand.phone} onSave={(v) => saveBrand('phone', v)} />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start gap-4">
|
<li className="flex items-start gap-4">
|
||||||
<MailIcon className="mt-1 h-5 w-5 shrink-0 text-copper" />
|
<MailIcon className="mt-1 h-5 w-5 shrink-0 text-copper" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-bold text-ivory">{content.email_label}</p>
|
<p className="text-sm font-bold text-ivory">
|
||||||
|
<InlineEdit value={content.email_label} onSave={(v) => saveContent('email_label', v)} />
|
||||||
|
</p>
|
||||||
<a
|
<a
|
||||||
href={`mailto:${brand.email}`}
|
href={`mailto:${brand.email}`}
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
|
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
|
||||||
>
|
>
|
||||||
{brand.email}
|
<InlineEdit value={brand.email} onSave={(v) => saveBrand('email', v)} />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -144,13 +168,16 @@ export function ContactSection() {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border border-ivory/20 bg-char/45 p-8 backdrop-blur-[9px] md:p-10 lg:col-span-7">
|
<div className="relative border border-ivory/20 bg-char/45 p-8 backdrop-blur-[9px] md:p-10 lg:col-span-7">
|
||||||
|
<SingletonPill resource="contact_section" label="ویرایش برچسبها و متنهای فرم" className="absolute left-4 top-4" />
|
||||||
{sent ? (
|
{sent ? (
|
||||||
<div className="flex h-full flex-col items-center justify-center py-14 text-center">
|
<div className="flex h-full flex-col items-center justify-center py-14 text-center">
|
||||||
<CheckCircle2Icon className="h-12 w-12 text-copper" />
|
<CheckCircle2Icon className="h-12 w-12 text-copper" />
|
||||||
<h3 className="mt-6 text-xl font-bold text-ivory">{content.success_title}</h3>
|
<h3 className="mt-6 text-xl font-bold text-ivory">
|
||||||
|
<InlineEdit value={content.success_title} onSave={(v) => saveContent('success_title', v)} />
|
||||||
|
</h3>
|
||||||
<p className="mt-3 max-w-sm text-sm font-light leading-8 text-ivory/70">
|
<p className="mt-3 max-w-sm text-sm font-light leading-8 text-ivory/70">
|
||||||
{content.success_text}
|
<InlineEdit multiline value={content.success_text} onSave={(v) => saveContent('success_text', v)} />
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -161,14 +188,14 @@ export function ContactSection() {
|
|||||||
setForm({ name: '', phone: '', note: '' })
|
setForm({ name: '', phone: '', note: '' })
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{content.success_button_label}
|
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2">
|
<form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="contact-name" className="block text-sm font-bold text-ivory/90">
|
<label htmlFor="contact-name" className="block text-sm font-bold text-ivory/90">
|
||||||
{content.name_label}
|
<InlineEdit value={content.name_label} onSave={(v) => saveContent('name_label', v)} />
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="contact-name"
|
id="contact-name"
|
||||||
@@ -182,7 +209,7 @@ export function ContactSection() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="contact-phone" className="block text-sm font-bold text-ivory/90">
|
<label htmlFor="contact-phone" className="block text-sm font-bold text-ivory/90">
|
||||||
{content.phone_field_label} <span className="text-copper">*</span>
|
<InlineEdit value={content.phone_field_label} onSave={(v) => saveContent('phone_field_label', v)} /> <span className="text-copper">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="contact-phone"
|
id="contact-phone"
|
||||||
@@ -198,7 +225,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-bold text-ivory/90">
|
<label htmlFor="contact-note" className="block text-sm font-bold text-ivory/90">
|
||||||
{content.note_label} <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">{content.note_optional_label}</span>
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="contact-note"
|
id="contact-note"
|
||||||
@@ -212,7 +239,9 @@ export function ContactSection() {
|
|||||||
|
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<Button type="submit" variant="copper" className="w-full md:w-auto md:px-12" disabled={sending}>
|
<Button type="submit" variant="copper" className="w-full md:w-auto md:px-12" disabled={sending}>
|
||||||
{sending ? content.sending_label : content.submit_label}
|
{sending ? content.sending_label : (
|
||||||
|
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -222,4 +251,4 @@ export function ContactSection() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import React from 'react';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react';
|
import { InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react';
|
||||||
import { useSite } from '../api';
|
import { useSite } from '../api';
|
||||||
|
import { useFieldSaver } from '../admin/AdminContext';
|
||||||
|
import { InlineEdit } from './admin/InlineEdit';
|
||||||
|
import { AddItemButton, EditableItem, SingletonPill } from '../admin/Editable';
|
||||||
|
|
||||||
const SOCIAL_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
const SOCIAL_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||||
instagram: InstagramIcon,
|
instagram: InstagramIcon,
|
||||||
@@ -11,14 +14,30 @@ const SOCIAL_ICONS: Record<string, React.ComponentType<{ className?: string }>>
|
|||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
const { brand, services, nav, footer } = useSite();
|
const { brand, services, nav, footer } = useSite();
|
||||||
|
const save = useFieldSaver();
|
||||||
|
|
||||||
|
const saveFooter = (field: string, value: string) =>
|
||||||
|
save('footer', footer.id, { [field]: value }, (d) => {
|
||||||
|
;(d.footer as unknown as Record<string, unknown>)[field] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveNav = (id: number, changes: Record<string, unknown>) =>
|
||||||
|
save('nav', id, changes, (d) => {
|
||||||
|
const item = d.nav.find((n) => n.id === id);
|
||||||
|
if (item) Object.assign(item, changes);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="border-t border-ivory/10 bg-char">
|
<footer className="border-t border-ivory/10 bg-char">
|
||||||
<div className="mx-auto max-w-site px-6 py-16 lg:px-10">
|
<div className="mx-auto max-w-site px-6 py-16 lg:px-10">
|
||||||
<div className="grid gap-12 lg:grid-cols-12">
|
<div className="grid gap-12 lg:grid-cols-12">
|
||||||
<div className="lg:col-span-4">
|
<div className="lg:col-span-4">
|
||||||
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" />
|
<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 -left-10 top-1/2 -translate-y-1/2" />
|
||||||
|
</div>
|
||||||
<p className="mt-6 max-w-sm text-sm font-light leading-8 text-ivory/55">
|
<p className="mt-6 max-w-sm text-sm font-light leading-8 text-ivory/55">
|
||||||
{footer.about_text}
|
<InlineEdit multiline value={footer.about_text} onSave={(v) => saveFooter('about_text', v)} />
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6 flex gap-3">
|
<div className="mt-6 flex gap-3">
|
||||||
{footer.socials.map(({ label, href, icon }) => {
|
{footer.socials.map(({ label, href, icon }) => {
|
||||||
@@ -38,26 +57,38 @@ export function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<h3 className="text-sm font-bold text-copper">{footer.pages_heading}</h3>
|
<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">
|
<ul className="mt-5 space-y-3">
|
||||||
{nav.map((item) =>
|
{nav.map((item) =>
|
||||||
<li key={item.href}>
|
<EditableItem key={item.id} resource="nav" item={item}>
|
||||||
<Link
|
<li>
|
||||||
|
<Link
|
||||||
to={item.href}
|
to={item.href}
|
||||||
className="text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper">
|
className="text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper">
|
||||||
|
|
||||||
{item.label}
|
<InlineEdit
|
||||||
|
value={item.label}
|
||||||
|
onSave={(v) => saveNav(item.id, { label: v })}
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
</EditableItem>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
|
<div className="mt-4">
|
||||||
|
<AddItemButton resource="nav" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-3">
|
<div className="lg:col-span-3">
|
||||||
<h3 className="text-sm font-bold text-copper">{footer.services_heading}</h3>
|
<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">
|
<ul className="mt-5 space-y-3">
|
||||||
{services.map((service) =>
|
{services.map((service) =>
|
||||||
<li key={service.title} className="text-sm font-light text-ivory/60">
|
<li key={service.id} className="text-sm font-light text-ivory/60">
|
||||||
{service.title}
|
{service.title}
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
@@ -65,7 +96,9 @@ export function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-3">
|
<div className="lg:col-span-3">
|
||||||
<h3 className="text-sm font-bold text-copper">{footer.contact_heading}</h3>
|
<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">
|
<ul className="mt-5 space-y-4 text-sm font-light text-ivory/60">
|
||||||
<li className="flex items-start gap-3">
|
<li className="flex items-start gap-3">
|
||||||
<MapPinIcon className="mt-1 h-4 w-4 shrink-0 text-copper" />
|
<MapPinIcon className="mt-1 h-4 w-4 shrink-0 text-copper" />
|
||||||
@@ -88,8 +121,12 @@ export function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-14 flex flex-col items-center justify-between gap-4 border-t border-ivory/10 pt-6 text-xs font-light text-ivory/40 md:flex-row">
|
<div className="mt-14 flex flex-col items-center justify-between gap-4 border-t border-ivory/10 pt-6 text-xs font-light text-ivory/40 md:flex-row">
|
||||||
<p>{footer.copyright}</p>
|
<p>
|
||||||
<p>{footer.tagline}</p>
|
<InlineEdit value={footer.copyright} onSave={(v) => saveFooter('copyright', v)} />
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<InlineEdit value={footer.tagline} onSave={(v) => saveFooter('tagline', v)} />
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>);
|
</footer>);
|
||||||
|
|||||||
@@ -2,9 +2,19 @@ import React, { useEffect, useState } from "react";
|
|||||||
import { Link, NavLink, useLocation } from "react-router-dom";
|
import { Link, NavLink, useLocation } from "react-router-dom";
|
||||||
import { MenuIcon, PhoneIcon, XIcon } from "lucide-react";
|
import { MenuIcon, PhoneIcon, XIcon } from "lucide-react";
|
||||||
import { useSite } from "../api";
|
import { useSite } from "../api";
|
||||||
|
import { useFieldSaver } from "../admin/AdminContext";
|
||||||
|
import { InlineEdit } from "./admin/InlineEdit";
|
||||||
|
import { EditableItem, SingletonPill } from "../admin/Editable";
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const { brand, nav } = useSite();
|
const { brand, nav } = useSite();
|
||||||
|
const save = useFieldSaver();
|
||||||
|
|
||||||
|
const saveNav = (id: number, changes: Record<string, unknown>) =>
|
||||||
|
save("nav", id, changes, (d) => {
|
||||||
|
const item = d.nav.find((n) => n.id === id);
|
||||||
|
if (item) Object.assign(item, changes);
|
||||||
|
});
|
||||||
const [scrolled, setScrolled] = useState(false);
|
const [scrolled, setScrolled] = useState(false);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
@@ -28,6 +38,7 @@ export function Header() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="mx-auto flex max-w-site items-center justify-between gap-8 px-6 py-4 lg:px-10">
|
<div className="mx-auto flex max-w-site items-center justify-between gap-8 px-6 py-4 lg:px-10">
|
||||||
|
<div className="relative flex items-center">
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
className="flex items-center gap-3"
|
className="flex items-center gap-3"
|
||||||
@@ -41,11 +52,14 @@ export function Header() {
|
|||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
|
<SingletonPill resource="brand" label="ویرایش برند و لوگو" className="absolute -left-10 top-1/2 -translate-y-1/2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<nav aria-label="ناوبری اصلی" className="hidden xl:block">
|
<nav aria-label="ناوبری اصلی" className="hidden xl:block">
|
||||||
<ul className="flex items-center gap-7">
|
<ul className="flex items-center gap-7">
|
||||||
{nav.map((item) => (
|
{nav.map((item) => (
|
||||||
<li key={item.href}>
|
<EditableItem key={item.id} resource="nav" item={item}>
|
||||||
|
<li>
|
||||||
<NavLink
|
<NavLink
|
||||||
to={item.href}
|
to={item.href}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
@@ -56,7 +70,10 @@ export function Header() {
|
|||||||
>
|
>
|
||||||
{({ isActive }) => (
|
{({ isActive }) => (
|
||||||
<>
|
<>
|
||||||
{item.label}
|
<InlineEdit
|
||||||
|
value={item.label}
|
||||||
|
onSave={(v) => saveNav(item.id, { label: v })}
|
||||||
|
/>
|
||||||
<span
|
<span
|
||||||
className={`absolute -bottom-0.5 left-0 right-0 h-px w-full origin-center bg-copper transition-transform duration-300 ease-lux ${
|
className={`absolute -bottom-0.5 left-0 right-0 h-px w-full origin-center bg-copper transition-transform duration-300 ease-lux ${
|
||||||
isActive
|
isActive
|
||||||
@@ -68,6 +85,7 @@ export function Header() {
|
|||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</li>
|
</li>
|
||||||
|
</EditableItem>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -109,7 +127,8 @@ export function Header() {
|
|||||||
>
|
>
|
||||||
<ul className="grid gap-1">
|
<ul className="grid gap-1">
|
||||||
{nav.map((item) => (
|
{nav.map((item) => (
|
||||||
<li key={item.href}>
|
<EditableItem key={item.id} resource="nav" item={item}>
|
||||||
|
<li>
|
||||||
<NavLink
|
<NavLink
|
||||||
to={item.href}
|
to={item.href}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
@@ -118,9 +137,13 @@ export function Header() {
|
|||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{item.label}
|
<InlineEdit
|
||||||
|
value={item.label}
|
||||||
|
onSave={(v) => saveNav(item.id, { label: v })}
|
||||||
|
/>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</li>
|
</li>
|
||||||
|
</EditableItem>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -2,52 +2,80 @@ import React from 'react'
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { ChevronLeftIcon } from 'lucide-react'
|
import { ChevronLeftIcon } from 'lucide-react'
|
||||||
|
import { usePageHero } from '../api'
|
||||||
|
import { useFieldSaver } from '../admin/AdminContext'
|
||||||
|
import { InlineEdit } from './admin/InlineEdit'
|
||||||
|
import { ImageField } from './admin/ImageField'
|
||||||
|
|
||||||
type PageHeroProps = {
|
type PageHeroProps = {
|
||||||
title: string
|
/** کلید هیرو صفحه در دیتابیس — عنوان/توضیح درجا ویرایشپذیر میشوند */
|
||||||
lead: string
|
pageKey: string
|
||||||
image: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageHero({ title, lead, image }: PageHeroProps) {
|
export function PageHero({ pageKey }: PageHeroProps) {
|
||||||
|
const hero = usePageHero(pageKey)
|
||||||
|
const save = useFieldSaver()
|
||||||
|
|
||||||
|
const saveField = (field: 'title' | 'lead' | 'image', value: string) =>
|
||||||
|
save('page-heroes', hero.id, { [field]: value }, (d) => {
|
||||||
|
const h = d.page_heroes.find((p) => p.id === hero.id)
|
||||||
|
if (h) (h as Record<string, unknown>)[field] = value
|
||||||
|
})
|
||||||
|
|
||||||
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">
|
||||||
{image && <img src={image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />}
|
{hero.image && <img src={hero.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />}
|
||||||
|
|
||||||
<div className="absolute inset-0 bg-ink/50" aria-hidden="true" />
|
<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="مسیر صفحه" className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
<nav aria-label="مسیر صفحه" className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
||||||
<Link to="/" className="transition-colors hover:text-copper">
|
<Link to="/" className="transition-colors hover:text-copper">
|
||||||
صفحه اصلی
|
صفحه اصلی
|
||||||
</Link>
|
</Link>
|
||||||
<ChevronLeftIcon className="h-3.5 w-3.5" />
|
<ChevronLeftIcon className="h-3.5 w-3.5" />
|
||||||
<span className="text-copper">{title}</span>
|
<span className="text-copper">{hero.title}</span>
|
||||||
</nav>
|
</nav>
|
||||||
<motion.h1
|
<motion.h1
|
||||||
initial={{ opacity: 0, y: 24 }}
|
initial={{ opacity: 0, y: 24 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.7, ease: [0.23, 1, 0.32, 1] }}
|
transition={{ duration: 0.7, ease: [0.23, 1, 0.32, 1] }}
|
||||||
className="mt-5 max-w-[700px] text-hero font-bold text-ivory"
|
className="mt-5 max-w-[700px]"
|
||||||
>
|
>
|
||||||
{title}
|
<InlineEdit
|
||||||
|
as="span"
|
||||||
|
value={hero.title}
|
||||||
|
onSave={(v) => saveField('title', v)}
|
||||||
|
className="block text-hero font-bold text-ivory"
|
||||||
|
/>
|
||||||
</motion.h1>
|
</motion.h1>
|
||||||
<motion.p
|
<motion.p
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.7, delay: 0.1, ease: [0.23, 1, 0.32, 1] }}
|
transition={{ duration: 0.7, delay: 0.1, ease: [0.23, 1, 0.32, 1] }}
|
||||||
className="mt-5 max-w-2xl text-base font-light leading-9 text-ivory/65"
|
className="mt-5 max-w-2xl"
|
||||||
>
|
>
|
||||||
{lead}
|
<InlineEdit
|
||||||
|
as="span"
|
||||||
|
multiline
|
||||||
|
value={hero.lead}
|
||||||
|
onSave={(v) => saveField('lead', v)}
|
||||||
|
className="block text-base font-light leading-9 text-ivory/65"
|
||||||
|
/>
|
||||||
</motion.p>
|
</motion.p>
|
||||||
<div className="mt-8 h-px w-28 bg-copper" />
|
<div className="mt-8 h-px w-28 bg-copper" />
|
||||||
|
|
||||||
|
{/* ویرایش تصویر پسزمینه — فقط در حالت ویرایش ادمین */}
|
||||||
|
<div className="mt-6 [&>div]:!mt-0">
|
||||||
|
<ImageField label="تصویر پسزمینه" value={hero.image} onSave={(url) => saveField('image', url)} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,58 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Reveal } from './Reveal'
|
import { Reveal } from './Reveal'
|
||||||
|
import { useSectionHeading } from '../api'
|
||||||
|
import { useFieldSaver } from '../admin/AdminContext'
|
||||||
|
import { InlineEdit } from './admin/InlineEdit'
|
||||||
|
|
||||||
type SectionHeadingProps = {
|
type SectionHeadingProps = {
|
||||||
title: string
|
/** در صورت ارسال، تیتر و توضیح از دیتابیس خوانده و بهصورت درجا ویرایشپذیر میشوند */
|
||||||
|
headingKey?: string
|
||||||
|
title?: string
|
||||||
lead?: string
|
lead?: string
|
||||||
align?: 'right' | 'center'
|
align?: 'right' | 'center'
|
||||||
tone?: 'copper' | 'ivory'
|
tone?: 'copper' | 'ivory'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SectionHeading({
|
export function SectionHeading({
|
||||||
|
headingKey,
|
||||||
title,
|
title,
|
||||||
lead,
|
lead,
|
||||||
align = 'right',
|
align = 'right',
|
||||||
tone = 'ivory',
|
tone = 'ivory',
|
||||||
}: SectionHeadingProps) {
|
}: SectionHeadingProps) {
|
||||||
|
const heading = useSectionHeading(headingKey ?? '')
|
||||||
|
const save = useFieldSaver()
|
||||||
|
const isDb = !!headingKey
|
||||||
|
const currentTitle = isDb ? heading.title : (title ?? '')
|
||||||
|
const currentLead = isDb ? heading.lead : (lead ?? '')
|
||||||
const isCenter = align === 'center'
|
const isCenter = align === 'center'
|
||||||
|
|
||||||
|
const saveField = (field: 'title' | 'lead', value: string) =>
|
||||||
|
save('section-headings', heading.id, { [field]: value }, (d) => {
|
||||||
|
const h = d.section_headings.find((s) => s.id === heading.id)
|
||||||
|
if (h) (h as Record<string, unknown>)[field] = value
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Reveal className={isCenter ? 'mx-auto max-w-2xl text-center' : 'max-w-2xl text-right'}>
|
<Reveal className={isCenter ? 'mx-auto max-w-2xl text-center' : 'max-w-2xl text-right'}>
|
||||||
<h2 className={`text-section font-bold ${tone === 'copper' ? 'text-copper' : 'text-ivory'}`}>
|
<InlineEdit
|
||||||
{title}
|
as="h2"
|
||||||
</h2>
|
value={currentTitle}
|
||||||
|
onSave={(v) => saveField('title', v)}
|
||||||
{lead && <p className="mt-5 text-base font-light leading-9 text-ivory/60">{lead}</p>}
|
className={`text-section font-bold ${tone === 'copper' ? 'text-copper' : 'text-ivory'}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{currentLead && (
|
||||||
|
<InlineEdit
|
||||||
|
as="p"
|
||||||
|
multiline
|
||||||
|
value={currentLead}
|
||||||
|
onSave={(v) => saveField('lead', v)}
|
||||||
|
className="mt-5 text-base font-light leading-9 text-ivory/60"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={`mt-6 h-px w-24 bg-copper/70 ${isCenter ? 'mx-auto' : ''}`} />
|
<div className={`mt-6 h-px w-24 bg-copper/70 ${isCenter ? 'mx-auto' : ''}`} />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import { ImagePlusIcon, Loader2Icon } from 'lucide-react'
|
||||||
|
import { adminApi } from '../../admin/adminClient'
|
||||||
|
import { useAdmin } from '../../admin/AdminContext'
|
||||||
|
|
||||||
|
type ImageFieldProps = {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
onSave: (url: string) => Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* انتخاب و آپلود تصویر به MinIO — در کشوهای ویرایش استفاده میشود.
|
||||||
|
* پس از آپلود موفق، URL نسبی /media/... ذخیره میشود.
|
||||||
|
*/
|
||||||
|
export function ImageField({ label, value, onSave }: ImageFieldProps) {
|
||||||
|
const { runSave, isAuthenticated, isEditMode } = useAdmin()
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
|
||||||
|
if (!isAuthenticated || !isEditMode) return null
|
||||||
|
|
||||||
|
const pick = async (file: File | undefined) => {
|
||||||
|
if (!file) return
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
await runSave(async () => {
|
||||||
|
const { url } = await adminApi.upload(file)
|
||||||
|
const ok = await onSave(url)
|
||||||
|
if (!ok) throw new Error('ذخیره نشد')
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-ivory/80">{label}</p>
|
||||||
|
<div className="mt-2 flex items-center gap-3">
|
||||||
|
{value ? (
|
||||||
|
<img
|
||||||
|
src={value}
|
||||||
|
alt={label}
|
||||||
|
className="h-16 w-24 border border-ivory/15 object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-16 w-24 items-center justify-center border border-dashed border-ivory/20 text-ivory/30">
|
||||||
|
—
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="flex items-center gap-2 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/80 transition-colors duration-200 hover:border-copper hover:text-copper disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<Loader2Icon className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ImagePlusIcon className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{uploading ? 'در حال آپلود…' : 'انتخاب تصویر'}
|
||||||
|
</button>
|
||||||
|
{value && <p className="mt-1.5 truncate text-[10px] font-light text-ivory/35" dir="ltr">{value}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
void pick(e.target.files?.[0])
|
||||||
|
e.target.value = ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import type { CSSProperties, ElementType, KeyboardEvent } from 'react'
|
||||||
|
import { useAdmin } from '../../admin/AdminContext'
|
||||||
|
|
||||||
|
type InlineEditProps = {
|
||||||
|
value: string
|
||||||
|
/** ذخیره async — در صورت خطا، متن به مقدار قبلی برمیگردد */
|
||||||
|
onSave: (value: string) => Promise<boolean> | boolean | void
|
||||||
|
as?: ElementType
|
||||||
|
/** چندخطی: Enter خط جدید میگذارد و فقط blur ذخیره میکند */
|
||||||
|
multiline?: boolean
|
||||||
|
className?: string
|
||||||
|
style?: CSSProperties
|
||||||
|
'aria-label'?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ویرایش درجا متن — در حالت عادی دقیقاً همان HTML قبلی را رندر میکند
|
||||||
|
* (بدون هیچ تغییر چیدمان) و فقط در Edit Mode برای ادمین قابل ویرایش میشود.
|
||||||
|
*/
|
||||||
|
export function InlineEdit({
|
||||||
|
value,
|
||||||
|
onSave,
|
||||||
|
as: Tag = 'span',
|
||||||
|
multiline = false,
|
||||||
|
className = '',
|
||||||
|
style,
|
||||||
|
...rest
|
||||||
|
}: InlineEditProps) {
|
||||||
|
const { isAuthenticated, isEditMode } = useAdmin()
|
||||||
|
const editing = isAuthenticated && isEditMode
|
||||||
|
const ref = useRef<HTMLElement>(null)
|
||||||
|
const [focused, setFocused] = useState(false)
|
||||||
|
|
||||||
|
// همگامسازی متن فقط وقتی المان در حال ویرایش نیست (پرش نشانگر رخ ندهد)
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current
|
||||||
|
if (el && !focused && el.innerText !== value) el.innerText = value
|
||||||
|
}, [value, focused])
|
||||||
|
|
||||||
|
if (!editing) {
|
||||||
|
return (
|
||||||
|
<Tag className={className} style={style} {...rest}>
|
||||||
|
{value}
|
||||||
|
</Tag>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const commit = async () => {
|
||||||
|
const el = ref.current
|
||||||
|
if (!el) return
|
||||||
|
setFocused(false)
|
||||||
|
const next = el.innerText.replace(/\u00a0/g, ' ')
|
||||||
|
if (next === value) return
|
||||||
|
el.innerText = value // بازگشت فوری به مقدار فعلی؛ در صورت موفقیت، بهروزرسانی خوشبینانه متن تازه میآید
|
||||||
|
await onSave(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent<HTMLElement>) => {
|
||||||
|
if (!multiline && e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
;(e.currentTarget as HTMLElement).blur()
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (ref.current) ref.current.innerText = value
|
||||||
|
;(e.currentTarget as HTMLElement).blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
ref={ref as never}
|
||||||
|
contentEditable
|
||||||
|
suppressContentEditableWarning
|
||||||
|
spellCheck={false}
|
||||||
|
data-inline-edit
|
||||||
|
aria-label={rest['aria-label']}
|
||||||
|
className={`${className} cursor-text rounded-sm transition-[outline-color,background-color] duration-150 outline-1 outline-dashed outline-transparent hover:bg-copper/5 hover:outline-copper/40 focus:bg-copper/5 focus:outline-copper focus:outline-solid`}
|
||||||
|
style={style}
|
||||||
|
onFocus={() => setFocused(true)}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Tag>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,28 +4,43 @@ import { Reveal } from '../Reveal';
|
|||||||
import { ButtonLink } from '../Button';
|
import { ButtonLink } from '../Button';
|
||||||
import { ArticleCard } from '../ArticleCard';
|
import { ArticleCard } from '../ArticleCard';
|
||||||
import { useSectionHeading, useSite } from '../../api';
|
import { useSectionHeading, useSite } from '../../api';
|
||||||
|
import { useFieldSaver } from '../../admin/AdminContext';
|
||||||
|
import { InlineEdit } from '../admin/InlineEdit';
|
||||||
|
import { AddItemButton, EditableItem } from '../../admin/Editable';
|
||||||
|
|
||||||
export function Articles() {
|
export function Articles() {
|
||||||
const { articles } = useSite();
|
const { articles } = useSite();
|
||||||
const heading = useSectionHeading('articles');
|
const heading = useSectionHeading('articles');
|
||||||
|
const save = useFieldSaver();
|
||||||
|
|
||||||
|
const saveHeading = (field: 'label' | 'title' | 'lead' | 'cta_label', value: string) =>
|
||||||
|
save('section-headings', heading.id, { [field]: value }, (d) => {
|
||||||
|
const h = d.section_headings.find((s) => s.id === heading.id);
|
||||||
|
if (h) (h as Record<string, unknown>)[field] = value;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-char py-24 lg:py-32" aria-labelledby="articles-title">
|
<section className="bg-char 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-6 lg:px-10">
|
||||||
<Reveal className="max-w-2xl text-right">
|
<Reveal className="max-w-2xl text-right">
|
||||||
<p className="text-sm font-light text-copper">{heading.label}</p>
|
<p className="text-sm font-light text-copper">
|
||||||
|
<InlineEdit value={heading.label} onSave={(v) => saveHeading('label', v)} />
|
||||||
|
</p>
|
||||||
<h2 id="articles-title" className="mt-5 text-section font-bold text-ivory">
|
<h2 id="articles-title" className="mt-5 text-section font-bold text-ivory">
|
||||||
{heading.title}
|
<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-5 text-base font-light leading-9 text-ivory/60">
|
||||||
{heading.lead}
|
<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-6 h-px w-24 bg-copper/70" />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<div className="mt-14 grid gap-6 lg:grid-cols-3">
|
<div className="mt-14 grid gap-6 lg:grid-cols-3">
|
||||||
{articles.slice(0, 3).map((article, i) =>
|
{articles.slice(0, 3).map((article, i) =>
|
||||||
<Reveal key={article.slug} delay={0.06 * i} className="h-full">
|
<Reveal key={article.id} delay={0.06 * i} className="h-full">
|
||||||
<ArticleCard article={article} />
|
<EditableItem resource="articles" item={article} className="h-full">
|
||||||
|
<ArticleCard article={article} />
|
||||||
|
</EditableItem>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -34,13 +49,16 @@ export function Articles() {
|
|||||||
<Reveal delay={0.1}>
|
<Reveal delay={0.1}>
|
||||||
<div className="mt-12 flex justify-center">
|
<div className="mt-12 flex justify-center">
|
||||||
<ButtonLink to={heading.cta_href} variant="outline" className="font-light">
|
<ButtonLink to={heading.cta_href} variant="outline" className="font-light">
|
||||||
{heading.cta_label}
|
<InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} />
|
||||||
<ArrowLeftIcon className="h-4 w-4" />
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
</ButtonLink>
|
</ButtonLink>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
|
<div className="mt-6">
|
||||||
|
<AddItemButton resource="articles" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>);
|
</section>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { MoveHorizontalIcon } from 'lucide-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 { SingletonPill } from '../../admin/Editable'
|
||||||
|
|
||||||
export function BeforeAfter() {
|
export function BeforeAfter() {
|
||||||
const { before_after } = useSite()
|
const { before_after } = useSite()
|
||||||
@@ -54,6 +55,10 @@ export function BeforeAfter() {
|
|||||||
align="center"
|
align="center"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<SingletonPill resource="before_after" label="ویرایش تصاویر و برچسبهای قبل/بعد" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<Reveal delay={0.1}>
|
<Reveal delay={0.1}>
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { AnimatePresence, motion } from 'framer-motion'
|
|||||||
import { PlusIcon } from 'lucide-react'
|
import { PlusIcon } from 'lucide-react'
|
||||||
import { Reveal } from '../Reveal'
|
import { Reveal } from '../Reveal'
|
||||||
import { SectionHeading } from '../SectionHeading'
|
import { SectionHeading } from '../SectionHeading'
|
||||||
import { useSectionHeading, useSite } from '../../api'
|
import { useSite } from '../../api'
|
||||||
|
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||||
|
|
||||||
type FaqProps = {
|
type FaqProps = {
|
||||||
hideHeading?: boolean;
|
hideHeading?: boolean;
|
||||||
@@ -11,19 +12,19 @@ type FaqProps = {
|
|||||||
|
|
||||||
export function Faq({ hideHeading = false }: FaqProps) {
|
export function Faq({ hideHeading = false }: FaqProps) {
|
||||||
const { faqs } = useSite()
|
const { faqs } = useSite()
|
||||||
const heading = useSectionHeading('faq')
|
|
||||||
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="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"}>
|
||||||
|
|
||||||
{!hideHeading && (
|
{!hideHeading && (
|
||||||
<div className="lg:col-span-4">
|
<div className="lg:col-span-4">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
title={heading.title}
|
title="سوالات متداول"
|
||||||
lead={heading.lead}
|
lead="پاسخ کوتاه به پرتکرارترین پرسشهای سرمایهگذاران و خریداران."
|
||||||
|
headingKey="faq"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -33,43 +34,48 @@ export function Faq({ hideHeading = false }: FaqProps) {
|
|||||||
{faqs.map((faq, i) => {
|
{faqs.map((faq, i) => {
|
||||||
const isOpen = open === i
|
const isOpen = open === i
|
||||||
return (
|
return (
|
||||||
<Reveal key={faq.question} delay={0.04 * i}>
|
<Reveal key={faq.id} delay={0.04 * i}>
|
||||||
<li className="border-b border-ivory/10">
|
<EditableItem resource="faqs" item={faq}>
|
||||||
<button
|
<li className="border-b border-ivory/10">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setOpen(isOpen ? null : i)}
|
type="button"
|
||||||
aria-expanded={isOpen}
|
onClick={() => setOpen(isOpen ? null : i)}
|
||||||
className="flex w-full items-center justify-between gap-6 py-6 text-right transition-colors duration-200 ease-lux hover:text-copper"
|
aria-expanded={isOpen}
|
||||||
>
|
className="flex w-full items-center justify-between gap-6 py-6 text-right transition-colors duration-200 ease-lux hover:text-copper"
|
||||||
<span className={`text-base font-bold md:text-lg ${isOpen ? 'text-copper' : 'text-ivory'}`}>
|
>
|
||||||
{faq.question}
|
<span className={`text-base font-bold md:text-lg ${isOpen ? 'text-copper' : 'text-ivory'}`}>
|
||||||
</span>
|
{faq.question}
|
||||||
<PlusIcon
|
</span>
|
||||||
className={`h-5 w-5 shrink-0 text-copper transition-transform duration-300 ease-lux ${
|
<PlusIcon
|
||||||
isOpen ? 'rotate-45' : ''
|
className={`h-5 w-5 shrink-0 text-copper transition-transform duration-300 ease-lux ${
|
||||||
}`}
|
isOpen ? 'rotate-45' : ''
|
||||||
/>
|
}`}
|
||||||
</button>
|
/>
|
||||||
<AnimatePresence initial={false}>
|
</button>
|
||||||
{isOpen && (
|
<AnimatePresence initial={false}>
|
||||||
<motion.div
|
{isOpen && (
|
||||||
initial={{ height: 0, opacity: 0 }}
|
<motion.div
|
||||||
animate={{ height: 'auto', opacity: 1 }}
|
initial={{ height: 0, opacity: 0 }}
|
||||||
exit={{ height: 0, opacity: 0 }}
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
|
exit={{ height: 0, opacity: 0 }}
|
||||||
className="overflow-hidden"
|
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
|
||||||
>
|
className="overflow-hidden"
|
||||||
<p className="pb-7 pl-10 text-sm font-light leading-9 text-ivory/60">{faq.answer}</p>
|
>
|
||||||
</motion.div>
|
<p className="pb-7 pl-10 text-sm font-light leading-9 text-ivory/60">{faq.answer}</p>
|
||||||
)}
|
</motion.div>
|
||||||
</AnimatePresence>
|
)}
|
||||||
</li>
|
</AnimatePresence>
|
||||||
|
</li>
|
||||||
|
</EditableItem>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
<div className="mt-8">
|
||||||
|
<AddItemButton resource="faqs" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,27 @@ import { ButtonLink } from '../Button'
|
|||||||
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, useStatusLabel } from '../../api'
|
||||||
|
import { useFieldSaver } from '../../admin/AdminContext'
|
||||||
|
import { InlineEdit } from '../admin/InlineEdit'
|
||||||
|
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||||
import type { ProjectStatus } from '../../data/site'
|
import type { ProjectStatus } from '../../data/site'
|
||||||
|
|
||||||
export function FeaturedProjects() {
|
export function FeaturedProjects() {
|
||||||
const { projects } = useSite()
|
const { projects, status_labels } = useSite()
|
||||||
const heading = useSectionHeading('featured_projects')
|
const heading = useSectionHeading('featured_projects')
|
||||||
const doneLabel = useStatusLabel('done')
|
const save = useFieldSaver()
|
||||||
const progressLabel = useStatusLabel('progress')
|
const saveStatusLabel = (key: string, value: string) => {
|
||||||
|
const label = status_labels.find((s) => s.key === key)
|
||||||
|
if (!label) return
|
||||||
|
void save('status-labels', label.id, { label: value }, (d) => {
|
||||||
|
const s = d.status_labels.find((x) => x.id === label.id)
|
||||||
|
if (s) s.label = value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const FILTERS: { key: ProjectStatus; label: string }[] = [
|
const FILTERS: { key: ProjectStatus; label: string }[] = [
|
||||||
{ key: 'done', label: doneLabel },
|
{ key: 'done', label: useStatusLabel('done') },
|
||||||
{ key: 'progress', label: progressLabel },
|
{ key: 'progress', label: useStatusLabel('progress') },
|
||||||
]
|
]
|
||||||
const [status, setStatus] = useState<ProjectStatus>('done')
|
const [status, setStatus] = useState<ProjectStatus>('done')
|
||||||
const list = projects.filter((p) => p.status === status).slice(0, 3)
|
const list = projects.filter((p) => p.status === status).slice(0, 3)
|
||||||
@@ -23,7 +34,7 @@ export function FeaturedProjects() {
|
|||||||
return (
|
return (
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="projects-title">
|
<section className="bg-ink 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-6 lg:px-10">
|
||||||
<SectionHeading title={heading.title} lead={heading.lead} />
|
<SectionHeading title={heading.title} lead={heading.lead} headingKey="featured_projects" />
|
||||||
|
|
||||||
<div className="mt-10 flex flex-wrap items-center gap-3" role="tablist" aria-label="فیلتر وضعیت پروژه">
|
<div className="mt-10 flex flex-wrap items-center gap-3" role="tablist" aria-label="فیلتر وضعیت پروژه">
|
||||||
{FILTERS.map((filter) => (
|
{FILTERS.map((filter) => (
|
||||||
@@ -46,7 +57,10 @@ export function FeaturedProjects() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="relative z-10 transition-colors duration-300 ease-out group-hover:text-char">
|
<span className="relative z-10 transition-colors duration-300 ease-out group-hover:text-char">
|
||||||
{filter.label}
|
<InlineEdit
|
||||||
|
value={filter.label}
|
||||||
|
onSave={(v) => saveStatusLabel(filter.key, v)}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -55,7 +69,7 @@ export function FeaturedProjects() {
|
|||||||
<div className="mt-10 min-h-[420px]">
|
<div className="mt-10 min-h-[420px]">
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
<motion.div
|
<motion.div
|
||||||
key={status}
|
key={status}
|
||||||
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 }}
|
exit={{ opacity: 0, y: -15 }}
|
||||||
@@ -64,13 +78,15 @@ export function FeaturedProjects() {
|
|||||||
>
|
>
|
||||||
{list.map((project, i) => (
|
{list.map((project, i) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={project.slug}
|
key={project.id}
|
||||||
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"
|
||||||
>
|
>
|
||||||
<ProjectCard project={project} />
|
<EditableItem resource="projects" item={project} className="h-full">
|
||||||
|
<ProjectCard project={project} />
|
||||||
|
</EditableItem>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -81,7 +97,15 @@ export function FeaturedProjects() {
|
|||||||
<Reveal delay={0.1}>
|
<Reveal delay={0.1}>
|
||||||
<div className="mt-12 flex justify-center">
|
<div className="mt-12 flex justify-center">
|
||||||
<ButtonLink to={heading.cta_href} variant="outline" className="font-light">
|
<ButtonLink to={heading.cta_href} variant="outline" className="font-light">
|
||||||
{heading.cta_label}
|
<InlineEdit
|
||||||
|
value={heading.cta_label}
|
||||||
|
onSave={(v) =>
|
||||||
|
save('section-headings', heading.id, { cta_label: v }, (d) => {
|
||||||
|
const h = d.section_headings.find((s) => s.id === heading.id)
|
||||||
|
if (h) h.cta_label = v
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
<ArrowLeftIcon className="h-4 w-4" />
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
</ButtonLink>
|
</ButtonLink>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,4 +114,4 @@ export function FeaturedProjects() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { AnimatePresence, motion } from 'framer-motion'
|
|||||||
import { ChevronLeftIcon, ChevronRightIcon, XIcon } from 'lucide-react'
|
import { ChevronLeftIcon, ChevronRightIcon, XIcon } from 'lucide-react'
|
||||||
import { Reveal } from '../Reveal'
|
import { Reveal } from '../Reveal'
|
||||||
import { SectionHeading } from '../SectionHeading'
|
import { SectionHeading } from '../SectionHeading'
|
||||||
import { useSectionHeading, useSite } from '../../api'
|
import { useSite } from '../../api'
|
||||||
|
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||||
|
|
||||||
export function Gallery() {
|
export function Gallery() {
|
||||||
const { gallery } = useSite()
|
const { gallery } = useSite()
|
||||||
const heading = useSectionHeading('gallery')
|
|
||||||
const [active, setActive] = useState<number | null>(null)
|
const [active, setActive] = useState<number | null>(null)
|
||||||
|
|
||||||
const close = useCallback(() => setActive(null), [])
|
const close = useCallback(() => setActive(null), [])
|
||||||
@@ -30,33 +30,37 @@ export function Gallery() {
|
|||||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="gallery-title">
|
<section className="bg-ink py-24 lg:py-32" aria-labelledby="gallery-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
|
||||||
title={heading.title}
|
title="گالری تصاویر"
|
||||||
lead={heading.lead}
|
lead="قابهایی از کارگاهها و پروژههای تحویلشده گروه ساختمانی آرامش."
|
||||||
|
headingKey="gallery"
|
||||||
align="center"
|
align="center"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-14 grid grid-cols-2 gap-4 lg:grid-cols-3">
|
<div className="mt-14 grid grid-cols-2 gap-4 lg:grid-cols-3">
|
||||||
{gallery.map((image, i) => (
|
{gallery.map((image, i) => (
|
||||||
<Reveal key={image.id} delay={0.04 * i}>
|
<Reveal key={image.id} delay={0.04 * i}>
|
||||||
<button
|
<EditableItem resource="gallery" item={image}>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setActive(i)}
|
type="button"
|
||||||
className="group block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
onClick={() => setActive(i)}
|
||||||
aria-label={`بزرگنمایی تصویر شماره ${(i + 1).toLocaleString('fa-IR')}`}
|
className="group block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||||
>
|
aria-label={`بزرگنمایی تصویر شماره ${(i + 1).toLocaleString('fa-IR')}`}
|
||||||
<img
|
>
|
||||||
src={image.src}
|
<img
|
||||||
alt={image.alt}
|
src={image.src}
|
||||||
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
|
alt={image.alt}
|
||||||
/>
|
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
|
||||||
</button>
|
/>
|
||||||
|
</button>
|
||||||
|
</EditableItem>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
|
<AddItemButton resource="gallery" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{active !== null && (
|
{active !== null && gallery[active] && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="fixed inset-0 z-[80] flex items-center justify-center bg-char/80 backdrop-blur-lg p-6"
|
className="fixed inset-0 z-[80] flex items-center justify-center bg-char/80 backdrop-blur-lg p-6"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
@@ -116,4 +120,4 @@ export function Gallery() {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,23 @@ import { motion, useScroll, useTransform } from 'framer-motion'
|
|||||||
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 { useFieldSaver } from '../../admin/AdminContext'
|
||||||
|
import { InlineEdit } from '../admin/InlineEdit'
|
||||||
|
import { SingletonPill } from '../../admin/Editable'
|
||||||
|
|
||||||
export function Hero() {
|
export function Hero() {
|
||||||
const { brand, hero } = useSite()
|
const { brand, hero } = useSite()
|
||||||
|
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])
|
||||||
const contentY = useTransform(scrollY, [0, 700], [0, -55])
|
const contentY = useTransform(scrollY, [0, 700], [0, -55])
|
||||||
const overlay = useTransform(scrollY, [0, 600], [0.5, 0.78])
|
const overlay = useTransform(scrollY, [0, 600], [0.5, 0.78])
|
||||||
|
|
||||||
|
const saveHero = (field: string, value: unknown) =>
|
||||||
|
save('hero', hero.id, { [field]: value }, (d) => {
|
||||||
|
;(d.hero as unknown as Record<string, unknown>)[field] = value
|
||||||
|
})
|
||||||
|
|
||||||
// واژه highlight در میانه تیتر با رنگ مسی رندر میشود
|
// واژه highlight در میانه تیتر با رنگ مسی رندر میشود
|
||||||
const renderTitle = () => {
|
const renderTitle = () => {
|
||||||
if (hero.title_highlight && hero.title.includes(hero.title_highlight)) {
|
if (hero.title_highlight && hero.title.includes(hero.title_highlight)) {
|
||||||
@@ -55,7 +64,7 @@ export function Hero() {
|
|||||||
transition={{ duration: 0.7, delay: 2.1, ease: [0.23, 1, 0.32, 1] }}
|
transition={{ duration: 0.7, delay: 2.1, ease: [0.23, 1, 0.32, 1] }}
|
||||||
className="text-sm font-light text-copper"
|
className="text-sm font-light text-copper"
|
||||||
>
|
>
|
||||||
{hero.kicker}
|
<InlineEdit value={hero.kicker} onSave={(v) => saveHero('kicker', v)} />
|
||||||
</motion.p>
|
</motion.p>
|
||||||
|
|
||||||
<motion.h1
|
<motion.h1
|
||||||
@@ -64,7 +73,7 @@ export function Hero() {
|
|||||||
transition={{ duration: 0.85, delay: 2.2, ease: [0.23, 1, 0.32, 1] }}
|
transition={{ duration: 0.85, delay: 2.2, ease: [0.23, 1, 0.32, 1] }}
|
||||||
className="mt-6 text-hero font-bold text-ivory"
|
className="mt-6 text-hero font-bold text-ivory"
|
||||||
>
|
>
|
||||||
{renderTitle()}
|
<InlineEdit value={hero.title} onSave={(v) => saveHero('title', v)} aria-label="تیتر اصلی صفحه" />
|
||||||
</motion.h1>
|
</motion.h1>
|
||||||
|
|
||||||
<motion.p
|
<motion.p
|
||||||
@@ -73,7 +82,7 @@ export function Hero() {
|
|||||||
transition={{ duration: 0.85, delay: 2.32, ease: [0.23, 1, 0.32, 1] }}
|
transition={{ duration: 0.85, delay: 2.32, ease: [0.23, 1, 0.32, 1] }}
|
||||||
className="mt-7 max-w-xl text-base font-light leading-9 text-ivory/70"
|
className="mt-7 max-w-xl text-base font-light leading-9 text-ivory/70"
|
||||||
>
|
>
|
||||||
{hero.subtitle}
|
<InlineEdit multiline value={hero.subtitle} onSave={(v) => saveHero('subtitle', v)} />
|
||||||
</motion.p>
|
</motion.p>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -83,11 +92,11 @@ export function Hero() {
|
|||||||
className="mt-10 flex flex-wrap items-center gap-4"
|
className="mt-10 flex flex-wrap items-center gap-4"
|
||||||
>
|
>
|
||||||
<ButtonLink to={hero.cta_primary_href} variant="copper">
|
<ButtonLink to={hero.cta_primary_href} variant="copper">
|
||||||
{hero.cta_primary_label}
|
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
|
||||||
<ArrowLeftIcon className="h-4 w-4" />
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
</ButtonLink>
|
</ButtonLink>
|
||||||
<ButtonLink to={hero.cta_secondary_href} variant="outline" className="font-light">
|
<ButtonLink to={hero.cta_secondary_href} variant="outline" className="font-light">
|
||||||
{hero.cta_secondary_label}
|
<InlineEdit value={hero.cta_secondary_label} onSave={(v) => saveHero('cta_secondary_label', v)} />
|
||||||
</ButtonLink>
|
</ButtonLink>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,8 +107,12 @@ export function Hero() {
|
|||||||
transition={{ duration: 0.9, delay: 2.6, ease: [0.23, 1, 0.32, 1] }}
|
transition={{ duration: 0.9, delay: 2.6, ease: [0.23, 1, 0.32, 1] }}
|
||||||
className="absolute bottom-28 left-6 hidden w-60 border border-ivory/15 bg-char/60 p-6 backdrop-blur-md lg:block lg:left-10"
|
className="absolute bottom-28 left-6 hidden w-60 border border-ivory/15 bg-char/60 p-6 backdrop-blur-md lg:block lg:left-10"
|
||||||
>
|
>
|
||||||
<p className="text-3xl font-bold text-copper">{hero.badge_value}</p>
|
<p className="text-3xl font-bold text-copper">
|
||||||
<p className="mt-2 text-sm font-light leading-7 text-ivory/70">{hero.badge_label}</p>
|
<InlineEdit value={hero.badge_value} onSave={(v) => saveHero('badge_value', v)} />
|
||||||
|
</p>
|
||||||
|
<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)} />
|
||||||
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -108,9 +121,17 @@ export function Hero() {
|
|||||||
aria-label="رفتن به بخش بعدی"
|
aria-label="رفتن به بخش بعدی"
|
||||||
className="absolute bottom-8 right-1/2 z-10 flex translate-x-1/2 flex-col items-center gap-2 text-ivory/50 transition-colors duration-200 ease-lux hover:text-copper"
|
className="absolute bottom-8 right-1/2 z-10 flex translate-x-1/2 flex-col items-center gap-2 text-ivory/50 transition-colors duration-200 ease-lux hover:text-copper"
|
||||||
>
|
>
|
||||||
<span className="text-xs font-light">{hero.scroll_label}</span>
|
<span className="text-xs font-light">
|
||||||
|
<InlineEdit value={hero.scroll_label} onSave={(v) => saveHero('scroll_label', v)} />
|
||||||
|
</span>
|
||||||
<ChevronDownIcon className="h-4 w-4 animate-bounce" />
|
<ChevronDownIcon className="h-4 w-4 animate-bounce" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<SingletonPill
|
||||||
|
resource="hero"
|
||||||
|
label="تنظیمات هیرو (واژه مسی، دکمهها)"
|
||||||
|
className="absolute left-6 top-24 z-20 lg:left-10"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,23 @@ import Swal from 'sweetalert2'
|
|||||||
import { Reveal } from '../Reveal'
|
import { Reveal } from '../Reveal'
|
||||||
import { Button } from '../Button'
|
import { Button } from '../Button'
|
||||||
import { useSite } from '../../api'
|
import { useSite } from '../../api'
|
||||||
|
import { useFieldSaver } from '../../admin/AdminContext'
|
||||||
|
import { InlineEdit } from '../admin/InlineEdit'
|
||||||
|
import { SingletonPill } from '../../admin/Editable'
|
||||||
import { API_URL } from '../../config'
|
import { API_URL } from '../../config'
|
||||||
|
|
||||||
export function Newsletter() {
|
export function Newsletter() {
|
||||||
const { newsletter } = useSite()
|
const { newsletter } = useSite()
|
||||||
|
const save = useFieldSaver()
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [status, setStatus] = useState<'idle' | 'error' | 'done'>('idle')
|
const [status, setStatus] = useState<'idle' | 'error' | 'done'>('idle')
|
||||||
const [sending, setSending] = useState(false)
|
const [sending, setSending] = useState(false)
|
||||||
|
|
||||||
|
const saveContent = (field: string, value: string) =>
|
||||||
|
save('newsletter', newsletter.id, { [field]: value }, (d) => {
|
||||||
|
;(d.newsletter as unknown as Record<string, unknown>)[field] = value
|
||||||
|
})
|
||||||
|
|
||||||
const showErrorAlert = (title: string, text: string) => {
|
const showErrorAlert = (title: string, text: string) => {
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
icon: 'warning',
|
icon: 'warning',
|
||||||
@@ -87,14 +96,15 @@ export function Newsletter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-copper py-16" aria-labelledby="newsletter-title">
|
<section className="relative bg-copper py-16" aria-labelledby="newsletter-title">
|
||||||
|
<SingletonPill resource="newsletter" label="ویرایش متنهای خبرنامه" className="absolute left-4 top-4" />
|
||||||
<div className="mx-auto grid max-w-site items-center gap-8 px-6 lg:grid-cols-2 lg:px-10">
|
<div className="mx-auto grid max-w-site items-center gap-8 px-6 lg:grid-cols-2 lg:px-10">
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<h2 id="newsletter-title" className="text-2xl font-bold leading-relaxed text-char md:text-3xl">
|
<h2 id="newsletter-title" className="text-2xl font-bold leading-relaxed text-char md:text-3xl">
|
||||||
{newsletter.title}
|
<InlineEdit value={newsletter.title} onSave={(v) => saveContent('title', v)} />
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-3 max-w-md text-sm font-light leading-8 text-char/75">
|
<p className="mt-3 max-w-md text-sm font-light leading-8 text-char/75">
|
||||||
{newsletter.lead}
|
<InlineEdit multiline value={newsletter.lead} onSave={(v) => saveContent('lead', v)} />
|
||||||
</p>
|
</p>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
@@ -127,11 +137,13 @@ export function Newsletter() {
|
|||||||
|
|
||||||
<Button type="submit" variant="dark-ivory" disabled={sending}>
|
<Button type="submit" variant="dark-ivory" disabled={sending}>
|
||||||
<SendIcon className="h-4 w-4" />
|
<SendIcon className="h-4 w-4" />
|
||||||
{sending ? newsletter.sending_label : newsletter.button_label}
|
{sending ? newsletter.sending_label : (
|
||||||
|
<InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,41 +2,55 @@ 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'
|
||||||
|
import { useFieldSaver } from '../../admin/AdminContext'
|
||||||
|
import { InlineEdit } from '../admin/InlineEdit'
|
||||||
|
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||||
|
|
||||||
export function Services() {
|
export function Services() {
|
||||||
const { services } = useSite()
|
const { services } = useSite()
|
||||||
const heading = useSectionHeading('services')
|
const heading = useSectionHeading('services')
|
||||||
|
const save = useFieldSaver()
|
||||||
|
|
||||||
|
const saveHeading = (field: 'title' | 'lead', value: string) =>
|
||||||
|
save('section-headings', heading.id, { [field]: value }, (d) => {
|
||||||
|
const h = d.section_headings.find((s) => s.id === heading.id)
|
||||||
|
if (h) h[field] = value
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="services-title">
|
<section className="bg-ink 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-6 lg:px-10">
|
||||||
<Reveal className="max-w-2xl text-right">
|
<Reveal className="max-w-2xl text-right">
|
||||||
|
|
||||||
<h2 id="services-title" className="text-section font-bold text-ivory">
|
<h2 id="services-title" className="text-section font-bold text-ivory">
|
||||||
{heading.title}
|
<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-5 text-base font-light leading-9 text-ivory/60">
|
||||||
{heading.lead}
|
<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-6 h-px w-24 bg-copper/70" />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<div className="mt-14 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="mt-14 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{services.map((service, i) => (
|
{services.map((service, i) => (
|
||||||
<Reveal key={service.title} delay={0.04 * i} className="h-full">
|
<Reveal key={service.id} delay={0.04 * i} 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">
|
<EditableItem resource="services" item={service} className="h-full">
|
||||||
<ServiceIcon
|
<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">
|
||||||
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-12 w-12 text-copper/85 transition-colors duration-300 ease-out group-hover:text-copper"
|
||||||
<h3 className="mt-6 text-lg font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
|
/>
|
||||||
{service.title}
|
<h3 className="mt-6 text-lg font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
|
||||||
</h3>
|
{service.title}
|
||||||
<p className="mt-3 text-sm font-light leading-8 text-ivory/55">{service.desc}</p>
|
</h3>
|
||||||
</article>
|
<p className="mt-3 text-sm font-light leading-8 text-ivory/55">{service.desc}</p>
|
||||||
|
</article>
|
||||||
|
</EditableItem>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
|
<AddItemButton resource="services" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { useInView } from 'framer-motion';
|
import { useInView } from 'framer-motion';
|
||||||
import { useSite } from '../../api';
|
import { useSite } from '../../api';
|
||||||
|
import { useFieldSaver } from '../../admin/AdminContext';
|
||||||
|
import { AddItemButton, EditableItem } from '../../admin/Editable';
|
||||||
|
import { InlineEdit } from '../admin/InlineEdit';
|
||||||
|
|
||||||
function toFa(n: number) {
|
function toFa(n: number) {
|
||||||
return n.toLocaleString('fa-IR');
|
return n.toLocaleString('fa-IR');
|
||||||
@@ -29,19 +32,39 @@ function Counter({ value }: {value: number;}) {
|
|||||||
|
|
||||||
export function Stats() {
|
export function Stats() {
|
||||||
const { stats } = useSite();
|
const { stats } = useSite();
|
||||||
|
const save = useFieldSaver();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-char py-16" aria-label="آمار و ارقام">
|
<section className="bg-char py-16" aria-label="آمار و ارقام">
|
||||||
<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-y-12 px-6 lg:grid-cols-4 lg:px-10">
|
||||||
{stats.map((stat) =>
|
{stats.map((stat) =>
|
||||||
<div key={stat.label} className="text-center">
|
<EditableItem key={stat.id} resource="stats" item={stat}>
|
||||||
|
<div className="text-center">
|
||||||
<p className="text-4xl font-bold text-copper md:text-5xl">
|
<p className="text-4xl font-bold text-copper md:text-5xl">
|
||||||
<Counter value={stat.value} />
|
<Counter value={stat.value} />
|
||||||
{stat.suffix && <span className="mr-1 text-2xl">{stat.suffix}</span>}
|
<InlineEdit
|
||||||
|
value={stat.suffix}
|
||||||
|
onSave={(v) => save('stats', stat.id, { suffix: v }, (d) => {
|
||||||
|
const s = d.stats.find((x) => x.id === stat.id);
|
||||||
|
if (s) s.suffix = v;
|
||||||
|
})}
|
||||||
|
className="mr-1 text-2xl"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-sm font-light text-ivory/55">
|
||||||
|
<InlineEdit
|
||||||
|
value={stat.label}
|
||||||
|
onSave={(v) => save('stats', stat.id, { label: v }, (d) => {
|
||||||
|
const s = d.stats.find((x) => x.id === stat.id);
|
||||||
|
if (s) s.label = v;
|
||||||
|
})}
|
||||||
|
/>
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-3 text-sm font-light text-ivory/55">{stat.label}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
</EditableItem>
|
||||||
)}
|
)}
|
||||||
|
<AddItemButton resource="stats" />
|
||||||
</div>
|
</div>
|
||||||
</section>);
|
</section>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { ArrowLeftIcon } from 'lucide-react'
|
|||||||
import { Reveal } from '../Reveal'
|
import { Reveal } from '../Reveal'
|
||||||
import { ButtonLink } from '../Button'
|
import { ButtonLink } from '../Button'
|
||||||
import { useSite } from '../../api'
|
import { useSite } from '../../api'
|
||||||
|
import { useFieldSaver } from '../../admin/AdminContext'
|
||||||
|
import { InlineEdit } from '../admin/InlineEdit'
|
||||||
|
import { SingletonPill } from '../../admin/Editable'
|
||||||
|
|
||||||
type StoryProps = {
|
type StoryProps = {
|
||||||
showLink?: boolean;
|
showLink?: boolean;
|
||||||
@@ -14,6 +17,19 @@ type StoryProps = {
|
|||||||
|
|
||||||
export function Story({ showLink = true, showLabel = true, showQuote = false, alignTop = false, children }: StoryProps) {
|
export function Story({ showLink = true, showLabel = true, showQuote = false, alignTop = false, children }: StoryProps) {
|
||||||
const { story } = useSite()
|
const { story } = useSite()
|
||||||
|
const save = useFieldSaver()
|
||||||
|
|
||||||
|
const saveStory = (field: string, value: unknown) =>
|
||||||
|
save('story', story.id, { [field]: value }, (d) => {
|
||||||
|
;(d.story as unknown as Record<string, unknown>)[field] = value
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveParagraph = (index: number, value: string) => {
|
||||||
|
const paragraphs = [...story.paragraphs]
|
||||||
|
paragraphs[index] = value
|
||||||
|
void saveStory('paragraphs', paragraphs)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="story" className="bg-ink py-24 lg:py-32" aria-labelledby="story-title">
|
<section id="story" className="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'}`}>
|
||||||
@@ -21,7 +37,7 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
|
|||||||
{showLabel && (
|
{showLabel && (
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<h2 id="story-title" className="text-section font-bold text-ivory">
|
<h2 id="story-title" className="text-section font-bold text-ivory">
|
||||||
{story.title}
|
<InlineEdit value={story.title} onSave={(v) => saveStory('title', v)} />
|
||||||
</h2>
|
</h2>
|
||||||
<div className="mt-6 h-px w-24 bg-copper/70" />
|
<div className="mt-6 h-px w-24 bg-copper/70" />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
@@ -30,7 +46,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 ${i === 0 ? 'mt-8' : 'mt-5'}`}>
|
||||||
{paragraph}
|
<InlineEdit multiline value={paragraph} onSave={(v) => saveParagraph(i, v)} />
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
</Reveal>
|
</Reveal>
|
||||||
@@ -38,12 +54,20 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
|
|||||||
<Reveal delay={0.16}>
|
<Reveal delay={0.16}>
|
||||||
<div className="mt-10 grid grid-cols-2 gap-8 border-t border-ivory/10 pt-8">
|
<div className="mt-10 grid grid-cols-2 gap-8 border-t border-ivory/10 pt-8">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-bold text-copper">{story.stat1_value}</p>
|
<p className="text-2xl font-bold text-copper">
|
||||||
<p className="mt-2 text-sm font-light text-ivory/55">{story.stat1_label}</p>
|
<InlineEdit value={story.stat1_value} onSave={(v) => saveStory('stat1_value', v)} />
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm font-light text-ivory/55">
|
||||||
|
<InlineEdit value={story.stat1_label} onSave={(v) => saveStory('stat1_label', v)} />
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-bold text-copper">{story.stat2_value}</p>
|
<p className="text-2xl font-bold text-copper">
|
||||||
<p className="mt-2 text-sm font-light text-ivory/55">{story.stat2_label}</p>
|
<InlineEdit value={story.stat2_value} onSave={(v) => saveStory('stat2_value', v)} />
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm font-light text-ivory/55">
|
||||||
|
<InlineEdit value={story.stat2_label} onSave={(v) => saveStory('stat2_label', v)} />
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
@@ -51,8 +75,12 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
|
|||||||
{showQuote && story.quote && (
|
{showQuote && story.quote && (
|
||||||
<Reveal delay={0.2}>
|
<Reveal delay={0.2}>
|
||||||
<div className="mt-12 border-r-2 border-copper bg-char/40 p-6">
|
<div className="mt-12 border-r-2 border-copper bg-char/40 p-6">
|
||||||
<p className="text-sm font-light leading-8 text-ivory/80">{story.quote}</p>
|
<p className="text-sm font-light leading-8 text-ivory/80">
|
||||||
<p className="mt-5 text-sm font-bold text-copper">{story.quote_author}</p>
|
<InlineEdit multiline value={story.quote} onSave={(v) => saveStory('quote', v)} />
|
||||||
|
</p>
|
||||||
|
<p className="mt-5 text-sm font-bold text-copper">
|
||||||
|
<InlineEdit value={story.quote_author} onSave={(v) => saveStory('quote_author', v)} />
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
@@ -60,7 +88,7 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
|
|||||||
{showLink && (
|
{showLink && (
|
||||||
<Reveal delay={0.22}>
|
<Reveal delay={0.22}>
|
||||||
<ButtonLink to={story.link_href} variant="outline" className="mt-10 font-light">
|
<ButtonLink to={story.link_href} variant="outline" className="mt-10 font-light">
|
||||||
{story.link_label}
|
<InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} />
|
||||||
<ArrowLeftIcon className="h-4 w-4" />
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
</ButtonLink>
|
</ButtonLink>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
@@ -73,7 +101,8 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="relative grid grid-cols-2 gap-4">
|
||||||
|
<SingletonPill resource="story" label="تصاویر و نقلقول بخش درباره ما" className="absolute -top-10 right-0" />
|
||||||
{story.bento.map((item, i) => (
|
{story.bento.map((item, i) => (
|
||||||
<Reveal key={item.src + i} delay={0.06 * i}>
|
<Reveal key={item.src + i} delay={0.06 * i}>
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
|
|||||||
@@ -1,37 +1,41 @@
|
|||||||
import React from 'react'
|
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 { useSite } from '../../api'
|
||||||
|
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||||
|
|
||||||
export function Team() {
|
export function Team() {
|
||||||
const { team } = useSite()
|
const { team } = useSite()
|
||||||
const heading = useSectionHeading('team')
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="team-title">
|
<section className="bg-ink 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-6 lg:px-10">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
title={heading.title}
|
title="تیم ما"
|
||||||
lead={heading.lead}
|
lead="کسانی که تصمیمهای اصلی هر پروژه را میگیرند و پاسخگوی آن هستند."
|
||||||
|
headingKey="team"
|
||||||
align="center"
|
align="center"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-16 grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="mt-16 grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{team.map((member, i) => (
|
{team.map((member, i) => (
|
||||||
<Reveal key={member.name} delay={0.06 * i} className="h-full">
|
<Reveal key={member.id} delay={0.06 * i} className="h-full">
|
||||||
<article className="group flex h-full flex-col items-center 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">
|
<EditableItem resource="team" item={member} className="h-full">
|
||||||
<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">
|
<article className="group flex h-full flex-col items-center 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">
|
||||||
{member.name.trim().charAt(0)}
|
<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">
|
||||||
</div>
|
{member.name.trim().charAt(0)}
|
||||||
<h3 className="mt-8 text-lg font-bold text-ivory">{member.name}</h3>
|
</div>
|
||||||
<p className="mt-2 text-sm font-light text-copper">{member.role}</p>
|
<h3 className="mt-8 text-lg font-bold text-ivory">{member.name}</h3>
|
||||||
<p className="mt-6 border-t border-ivory/10 pt-6 text-sm font-light text-ivory/60">
|
<p className="mt-2 text-sm font-light text-copper">{member.role}</p>
|
||||||
{member.bio}
|
<p className="mt-6 border-t border-ivory/10 pt-6 text-sm font-light text-ivory/60">
|
||||||
</p>
|
{member.bio}
|
||||||
</article>
|
</p>
|
||||||
|
</article>
|
||||||
|
</EditableItem>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
|
<AddItemButton resource="team" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import React, { useState } from 'react';
|
|||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } 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 { useSectionHeading, useSite } from '../../api';
|
import { useSite } from '../../api';
|
||||||
|
import { EditableItem } from '../../admin/Editable';
|
||||||
|
|
||||||
export function Testimonials() {
|
export function Testimonials() {
|
||||||
const { testimonials } = useSite();
|
const { testimonials } = useSite();
|
||||||
const heading = useSectionHeading('testimonials');
|
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
const item = testimonials[index];
|
const current = testimonials[Math.min(index, Math.max(testimonials.length - 1, 0))];
|
||||||
|
if (!current) return null;
|
||||||
|
|
||||||
const go = (dir: number) => {
|
const go = (dir: number) => {
|
||||||
setIndex((prev) => (prev + dir + testimonials.length) % testimonials.length);
|
setIndex((prev) => (prev + dir + testimonials.length) % testimonials.length);
|
||||||
@@ -17,23 +18,25 @@ export function Testimonials() {
|
|||||||
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-24 lg:py-32" 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 title={heading.title} 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-14 max-w-3xl text-center">
|
||||||
<QuoteIcon className="mx-auto h-8 w-8 text-copper/60" aria-hidden="true" />
|
<QuoteIcon className="mx-auto h-8 w-8 text-copper/60" aria-hidden="true" />
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
<motion.blockquote
|
<motion.blockquote
|
||||||
key={item.name}
|
key={current.id}
|
||||||
initial={{ opacity: 0, y: 16 }}
|
initial={{ opacity: 0, y: 16 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -12 }}
|
exit={{ opacity: 0, y: -12 }}
|
||||||
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}>
|
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}>
|
||||||
|
|
||||||
<p className="mt-8 text-lg font-light leading-[2.4rem] text-ivory/85 md:text-xl">«{item.quote}»</p>
|
<EditableItem resource="testimonials" item={current}>
|
||||||
<footer className="mt-8">
|
<p className="mt-8 text-lg font-light leading-[2.4rem] text-ivory/85 md:text-xl">«{current.quote}»</p>
|
||||||
<p className="text-base font-bold text-copper">{item.name}</p>
|
<footer className="mt-8">
|
||||||
<p className="mt-1 text-sm font-light text-ivory/45">{item.role}</p>
|
<p className="text-base font-bold text-copper">{current.name}</p>
|
||||||
</footer>
|
<p className="mt-1 text-sm font-light text-ivory/45">{current.role}</p>
|
||||||
|
</footer>
|
||||||
|
</EditableItem>
|
||||||
</motion.blockquote>
|
</motion.blockquote>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
@@ -43,13 +46,13 @@ export function Testimonials() {
|
|||||||
onClick={() => go(1)}
|
onClick={() => go(1)}
|
||||||
aria-label="نظر قبلی"
|
aria-label="نظر قبلی"
|
||||||
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory/70 transition-colors duration-200 ease-lux hover:border-copper hover:text-copper">
|
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory/70 transition-colors duration-200 ease-lux hover:border-copper hover:text-copper">
|
||||||
|
|
||||||
<ChevronRightIcon className="h-5 w-5" />
|
<ChevronRightIcon className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
<div className="flex gap-2" role="tablist" aria-label="انتخاب نظر">
|
<div className="flex gap-2" role="tablist" aria-label="انتخاب نظر">
|
||||||
{testimonials.map((t, i) =>
|
{testimonials.map((t, i) =>
|
||||||
<button
|
<button
|
||||||
key={t.name}
|
key={t.id}
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={i === index}
|
aria-selected={i === index}
|
||||||
@@ -66,7 +69,7 @@ export function Testimonials() {
|
|||||||
onClick={() => go(-1)}
|
onClick={() => go(-1)}
|
||||||
aria-label="نظر بعدی"
|
aria-label="نظر بعدی"
|
||||||
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory/70 transition-colors duration-200 ease-lux hover:border-copper hover:text-copper">
|
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory/70 transition-colors duration-200 ease-lux hover:border-copper hover:text-copper">
|
||||||
|
|
||||||
<ChevronLeftIcon className="h-5 w-5" />
|
<ChevronLeftIcon className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,4 +77,4 @@ export function Testimonials() {
|
|||||||
</div>
|
</div>
|
||||||
</section>);
|
</section>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useRef } from 'react'
|
import React, { useRef } from 'react'
|
||||||
import { motion, useScroll, useTransform, useInView } from 'framer-motion'
|
import { motion, useScroll, useTransform, useInView } from 'framer-motion'
|
||||||
import { useSectionHeading, useSite, type TimelineItem } from '../../api'
|
import { useSite, type TimelineItem } from '../../api'
|
||||||
import { SectionHeading } from '../SectionHeading'
|
import { SectionHeading } from '../SectionHeading'
|
||||||
|
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||||
|
|
||||||
const ease = [0.23, 1, 0.32, 1] as const
|
const ease = [0.23, 1, 0.32, 1] as const
|
||||||
|
|
||||||
@@ -31,11 +32,13 @@ function Item({ item, index }: { item: Milestone; index: number }) {
|
|||||||
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 }}
|
||||||
>
|
>
|
||||||
<div className="border border-ivory/10 bg-ink p-6 md:p-7">
|
<EditableItem resource="timeline" item={item}>
|
||||||
<span className="text-sm font-light text-copper">{item.year}</span>
|
<div className="border border-ivory/10 bg-ink p-6 md:p-7">
|
||||||
<h3 className="mt-3 text-lg font-bold text-ivory">{item.title}</h3>
|
<span className="text-sm font-light text-copper">{item.year}</span>
|
||||||
<p className="mt-3 text-sm font-light leading-8 text-ivory/60">{item.desc}</p>
|
<h3 className="mt-3 text-lg font-bold text-ivory">{item.title}</h3>
|
||||||
</div>
|
<p className="mt-3 text-sm font-light leading-8 text-ivory/60">{item.desc}</p>
|
||||||
|
</div>
|
||||||
|
</EditableItem>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
@@ -43,7 +46,6 @@ function Item({ item, index }: { item: Milestone; index: number }) {
|
|||||||
|
|
||||||
export function Timeline() {
|
export function Timeline() {
|
||||||
const { timeline } = useSite()
|
const { timeline } = useSite()
|
||||||
const heading = useSectionHeading('timeline')
|
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
const { scrollYProgress } = useScroll({
|
const { scrollYProgress } = useScroll({
|
||||||
target: ref,
|
target: ref,
|
||||||
@@ -54,11 +56,12 @@ export function Timeline() {
|
|||||||
return (
|
return (
|
||||||
<section className="bg-char py-24 md:py-32" aria-labelledby="timeline-title">
|
<section className="bg-char py-24 md:py-32" aria-labelledby="timeline-title">
|
||||||
<div className="mx-auto w-full max-w-site px-6 lg:px-10">
|
<div className="mx-auto w-full max-w-site px-6 lg:px-10">
|
||||||
|
|
||||||
<div className="mb-16">
|
<div className="mb-16">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
title={heading.title}
|
title="مسیر ما"
|
||||||
lead={heading.lead}
|
lead="از یک دفتر چهارنفره تا ۴۷ پروژه. خط زمان پروژههای ما را ببینید."
|
||||||
|
headingKey="timeline"
|
||||||
align="center"
|
align="center"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,9 +79,12 @@ export function Timeline() {
|
|||||||
|
|
||||||
<ol className="flex flex-col gap-12 md:gap-16">
|
<ol className="flex flex-col gap-12 md:gap-16">
|
||||||
{timeline.map((item, i) => (
|
{timeline.map((item, i) => (
|
||||||
<Item key={item.year} item={item} index={i} />
|
<Item key={item.id} item={item} index={i} />
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
<div className="mt-12">
|
||||||
|
<AddItemButton resource="timeline" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -2,17 +2,29 @@ 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 { InlineEdit } from '../admin/InlineEdit';
|
||||||
|
import { useFieldSaver } from '../../admin/AdminContext';
|
||||||
|
|
||||||
export function WhyUs() {
|
export function WhyUs() {
|
||||||
const { why_us } = useSite();
|
const { why_us } = useSite();
|
||||||
const heading = useSectionHeading('why_us');
|
const heading = useSectionHeading('why_us');
|
||||||
|
const save = useFieldSaver();
|
||||||
|
|
||||||
|
const saveItem = (id: number, changes: Record<string, unknown>) =>
|
||||||
|
save('why-us', id, changes, (d) => {
|
||||||
|
const it = d.why_us.find((x) => x.id === id);
|
||||||
|
if (it) Object.assign(it, changes);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="why-title">
|
<section className="bg-ink 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-14 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}
|
||||||
lead={heading.lead} />
|
lead={heading.lead}
|
||||||
|
headingKey="why_us" />
|
||||||
|
|
||||||
{heading.image && (
|
{heading.image && (
|
||||||
<Reveal delay={0.12}>
|
<Reveal delay={0.12}>
|
||||||
@@ -28,17 +40,33 @@ export function WhyUs() {
|
|||||||
<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">
|
<div className="grid gap-x-10 gap-y-px sm:grid-cols-2">
|
||||||
{why_us.map((item, i) =>
|
{why_us.map((item, i) =>
|
||||||
<Reveal key={item.title} delay={0.05 * 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">
|
<div className="flex h-full flex-col border-t border-ivory/10 py-8">
|
||||||
<span className="text-xs font-light text-copper">۰{(i + 1).toLocaleString('fa-IR')}</span>
|
<span className="text-xs font-light text-copper">۰{(i + 1).toLocaleString('fa-IR')}</span>
|
||||||
<h3 className="mt-4 text-lg font-bold text-ivory">{item.title}</h3>
|
<h3 className="mt-4 text-lg font-bold text-ivory">
|
||||||
<p className="mt-3 text-sm font-light leading-8 text-ivory/55">{item.desc}</p>
|
<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>
|
</div>
|
||||||
|
</EditableItem>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-8">
|
||||||
|
<AddItemButton resource="why_us" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>);
|
</section>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// انواع مشترک دادهها — همه محتوای متنی سایت از بکاند (GET /api/site) بارگذاری میشود.
|
// انواع مشترک دادهها — همه محتوای متنی سایت از بکاند (GET /api/site) بارگذاری میشود.
|
||||||
|
|
||||||
export type Service = {
|
export type Service = {
|
||||||
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
desc: string;
|
desc: string;
|
||||||
icon: 'draft' | 'tower' | 'mall' | 'villa' | 'renew' | 'manage' | 'invest' | 'landscape';
|
icon: 'draft' | 'tower' | 'mall' | 'villa' | 'renew' | 'manage' | 'invest' | 'landscape';
|
||||||
@@ -9,6 +10,7 @@ export type Service = {
|
|||||||
export type ProjectStatus = 'done' | 'progress';
|
export type ProjectStatus = 'done' | 'progress';
|
||||||
|
|
||||||
export type Project = {
|
export type Project = {
|
||||||
|
id: number;
|
||||||
slug: string;
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
location: string;
|
location: string;
|
||||||
@@ -25,5 +27,6 @@ export type Project = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type Article = {
|
export type Article = {
|
||||||
|
id: number;
|
||||||
slug: string; title: string; date: string; category: string; image: string; read: string; excerpt: string; body: string[];
|
slug: string; title: string; date: string; category: string; image: string; read: string; excerpt: string; body: string[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,13 +6,11 @@ import { Timeline } from '../components/home/Timeline'
|
|||||||
import { WhyUs } from '../components/home/WhyUs'
|
import { WhyUs } from '../components/home/WhyUs'
|
||||||
import { Team } from '../components/home/Team'
|
import { Team } from '../components/home/Team'
|
||||||
import { ContactSection } from '../components/ContactSection'
|
import { ContactSection } from '../components/ContactSection'
|
||||||
import { usePageHero } from '../api'
|
|
||||||
|
|
||||||
export function About() {
|
export function About() {
|
||||||
const hero = usePageHero('about')
|
return (
|
||||||
return (
|
|
||||||
<>
|
<>
|
||||||
<PageHero title={hero.title} lead={hero.lead} image={hero.image} />
|
<PageHero pageKey="about" />
|
||||||
|
|
||||||
<Story showLink={false} showLabel={false} showQuote={true} alignTop={true} />
|
<Story showLink={false} showLabel={false} showQuote={true} alignTop={true} />
|
||||||
|
|
||||||
|
|||||||
@@ -5,20 +5,21 @@ import { PageHero } from '../components/PageHero'
|
|||||||
import { Reveal } from '../components/Reveal'
|
import { Reveal } from '../components/Reveal'
|
||||||
import { ArticleCard } from '../components/ArticleCard'
|
import { ArticleCard } from '../components/ArticleCard'
|
||||||
import { Newsletter } from '../components/home/Newsletter'
|
import { Newsletter } from '../components/home/Newsletter'
|
||||||
import { usePageHero, useSite } from '../api'
|
import { useSite } from '../api'
|
||||||
|
import { AddItemButton, EditableItem } from '../admin/Editable'
|
||||||
|
|
||||||
export function Blog() {
|
export function Blog() {
|
||||||
const { articles } = useSite()
|
const { articles } = useSite()
|
||||||
const hero = usePageHero('blog')
|
const [featured, ...rest] = articles
|
||||||
const [featured, ...rest] = articles
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHero title={hero.title} lead={hero.lead} image={hero.image} />
|
<PageHero pageKey="blog" />
|
||||||
|
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-label="مقالات">
|
<section className="bg-ink py-24 lg:py-32" aria-label="مقالات">
|
||||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||||
<Reveal>
|
<Reveal>
|
||||||
|
<EditableItem resource="articles" item={featured}>
|
||||||
<Link
|
<Link
|
||||||
to={`/blog/${featured.slug}`}
|
to={`/blog/${featured.slug}`}
|
||||||
className="group grid items-stretch border border-ivory/10 bg-char transition-[transform,box-shadow,border-color] duration-300 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-lift lg:grid-cols-2"
|
className="group grid items-stretch border border-ivory/10 bg-char transition-[transform,box-shadow,border-color] duration-300 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-lift lg:grid-cols-2"
|
||||||
@@ -52,14 +53,18 @@ export function Blog() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
</EditableItem>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
||||||
{rest.map((article, i) => (
|
{rest.map((article, i) => (
|
||||||
<Reveal key={article.slug} delay={0.05 * i} className="h-full">
|
<Reveal key={article.id} delay={0.05 * i} className="h-full">
|
||||||
<ArticleCard article={article} />
|
<EditableItem resource="articles" item={article} className="h-full">
|
||||||
|
<ArticleCard article={article} />
|
||||||
|
</EditableItem>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
|
<AddItemButton resource="articles" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -2,20 +2,21 @@ 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 { Reveal } from '../components/Reveal'
|
import { Reveal } from '../components/Reveal'
|
||||||
import { usePageHero, useSite } from '../api'
|
import { useSite } from '../api'
|
||||||
|
import { AddItemButton, EditableItem } from '../admin/Editable'
|
||||||
|
|
||||||
export function ContactPage() {
|
export function ContactPage() {
|
||||||
const { offices } = useSite()
|
const { offices } = useSite()
|
||||||
const hero = usePageHero('contact')
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHero title={hero.title} lead={hero.lead} image={hero.image} />
|
<PageHero pageKey="contact" />
|
||||||
|
|
||||||
<section className="bg-ink py-24" aria-label="دفاتر ما">
|
<section className="bg-ink py-24" aria-label="دفاتر ما">
|
||||||
<div className="mx-auto grid max-w-site gap-6 px-6 md:grid-cols-3 lg:px-10">
|
<div className="mx-auto grid max-w-site gap-6 px-6 md:grid-cols-3 lg:px-10">
|
||||||
{offices.map((office, i) => (
|
{offices.map((office, i) => (
|
||||||
<Reveal key={office.id} delay={0.06 * i} className="h-full">
|
<Reveal key={office.id} delay={0.06 * i} className="h-full">
|
||||||
|
<EditableItem resource="offices" item={office} className="h-full">
|
||||||
<article className="flex h-full flex-col border border-ivory/10 bg-char px-8 py-12 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="flex h-full flex-col border border-ivory/10 bg-char px-8 py-12 text-center transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform">
|
||||||
<h2 className="text-2xl font-bold text-copper">{office.city}</h2>
|
<h2 className="text-2xl font-bold text-copper">{office.city}</h2>
|
||||||
<p className="mt-6 text-sm font-light leading-9 text-ivory/70">{office.address}</p>
|
<p className="mt-6 text-sm font-light leading-9 text-ivory/70">{office.address}</p>
|
||||||
@@ -25,8 +26,10 @@ export function ContactPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
</EditableItem>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
|
<AddItemButton resource="offices" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ import React from 'react'
|
|||||||
import { PageHero } from '../components/PageHero'
|
import { PageHero } from '../components/PageHero'
|
||||||
import { Faq } from '../components/home/Faq'
|
import { Faq } from '../components/home/Faq'
|
||||||
import { ContactSection } from '../components/ContactSection'
|
import { ContactSection } from '../components/ContactSection'
|
||||||
import { usePageHero } from '../api'
|
|
||||||
|
|
||||||
export function FaqPage() {
|
export function FaqPage() {
|
||||||
const hero = usePageHero('faq')
|
return (
|
||||||
return (
|
|
||||||
<>
|
<>
|
||||||
<PageHero title={hero.title} lead={hero.lead} image={hero.image} />
|
<PageHero pageKey="faq" />
|
||||||
|
|
||||||
<Faq hideHeading={true} />
|
<Faq hideHeading={true} />
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,16 @@ import { Reveal } from '../components/Reveal'
|
|||||||
import { SectionHeading } from '../components/SectionHeading'
|
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 { usePageHero, useSectionHeading, useSite } from '../api'
|
import { useSectionHeading, useSite } from '../api'
|
||||||
|
import { AddItemButton, EditableItem } from '../admin/Editable'
|
||||||
|
|
||||||
export function Investment() {
|
export function Investment() {
|
||||||
const { investment_models } = useSite()
|
const { investment_models } = useSite()
|
||||||
const hero = usePageHero('investment')
|
const heading = useSectionHeading('investment_models')
|
||||||
const heading = useSectionHeading('investment_models')
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHero title={hero.title} lead={hero.lead} image={hero.image} />
|
<PageHero pageKey="investment" />
|
||||||
|
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="models-title">
|
<section className="bg-ink 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">
|
||||||
@@ -21,6 +21,7 @@ export function Investment() {
|
|||||||
<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">
|
||||||
<article className="group flex h-full flex-col border border-ivory/10 bg-char p-10 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 border border-ivory/10 bg-char p-10 transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform">
|
||||||
<p className="text-sm font-bold text-copper/80">۰{(i + 1).toLocaleString('fa-IR')}</p>
|
<p className="text-sm font-bold text-copper/80">۰{(i + 1).toLocaleString('fa-IR')}</p>
|
||||||
|
|
||||||
@@ -41,8 +42,10 @@ export function Investment() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
</EditableItem>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
|
<AddItemButton resource="investment_models" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -5,11 +5,24 @@ import { ProjectCard } from '../components/ProjectCard';
|
|||||||
import { Gallery } from '../components/home/Gallery';
|
import { Gallery } from '../components/home/Gallery';
|
||||||
import { ContactSection } from '../components/ContactSection';
|
import { ContactSection } from '../components/ContactSection';
|
||||||
import type { ProjectStatus } from '../data/site';
|
import type { ProjectStatus } from '../data/site';
|
||||||
import { usePageHero, useSite, useStatusLabel } from '../api';
|
import { useSite, useStatusLabel } from '../api';
|
||||||
|
import { useFieldSaver } from '../admin/AdminContext';
|
||||||
|
import { InlineEdit } from '../components/admin/InlineEdit';
|
||||||
|
import { AddItemButton, EditableItem } from '../admin/Editable';
|
||||||
|
|
||||||
export function Projects() {
|
export function Projects() {
|
||||||
const { projects } = useSite();
|
const { projects, status_labels } = useSite();
|
||||||
const hero = usePageHero('projects');
|
const save = useFieldSaver();
|
||||||
|
|
||||||
|
const saveStatusLabel = (key: string, value: string) => {
|
||||||
|
const label = status_labels.find((s) => s.key === key);
|
||||||
|
if (!label) return;
|
||||||
|
void save('status-labels', label.id, { label: value }, (d) => {
|
||||||
|
const s = d.status_labels.find((x) => x.id === label.id);
|
||||||
|
if (s) s.label = value;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const doneLabel = useStatusLabel('done');
|
const doneLabel = useStatusLabel('done');
|
||||||
const progressLabel = useStatusLabel('progress');
|
const progressLabel = useStatusLabel('progress');
|
||||||
const FILTERS: {key: ProjectStatus;label: string;}[] = [
|
const FILTERS: {key: ProjectStatus;label: string;}[] = [
|
||||||
@@ -20,7 +33,7 @@ export function Projects() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHero title={hero.title} lead={hero.lead} image={hero.image} />
|
<PageHero pageKey="projects" />
|
||||||
|
|
||||||
|
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-label="فهرست پروژهها">
|
<section className="bg-ink py-24 lg:py-32" aria-label="فهرست پروژهها">
|
||||||
@@ -46,7 +59,10 @@ export function Projects() {
|
|||||||
|
|
||||||
}
|
}
|
||||||
<span className="relative z-10 transition-colors duration-300 ease-lux group-hover:text-char">
|
<span className="relative z-10 transition-colors duration-300 ease-lux group-hover:text-char">
|
||||||
{filter.label}
|
<InlineEdit
|
||||||
|
value={filter.label}
|
||||||
|
onSave={(v) => saveStatusLabel(filter.key, v)}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -54,10 +70,13 @@ export function Projects() {
|
|||||||
|
|
||||||
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
||||||
{list.map((project, i) =>
|
{list.map((project, i) =>
|
||||||
<Reveal key={project.slug} delay={0.05 * i} className="h-full">
|
<Reveal key={project.id} delay={0.05 * i} className="h-full">
|
||||||
<ProjectCard project={project} imageClass="h-80" />
|
<EditableItem resource="projects" item={project} className="h-full">
|
||||||
|
<ProjectCard project={project} imageClass="h-80" />
|
||||||
|
</EditableItem>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
|
<AddItemButton resource="projects" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -5,16 +5,16 @@ import { Faq } from '../components/home/Faq'
|
|||||||
import { ContactSection } from '../components/ContactSection'
|
import { ContactSection } from '../components/ContactSection'
|
||||||
import { Reveal } from '../components/Reveal'
|
import { Reveal } from '../components/Reveal'
|
||||||
import { SectionHeading } from '../components/SectionHeading'
|
import { SectionHeading } from '../components/SectionHeading'
|
||||||
import { usePageHero, useSectionHeading, useSite } from '../api'
|
import { useSectionHeading, useSite } from '../api'
|
||||||
|
import { AddItemButton, EditableItem } from '../admin/Editable'
|
||||||
|
|
||||||
export function ServicesPage() {
|
export function ServicesPage() {
|
||||||
const { process_steps } = useSite()
|
const { process_steps } = useSite()
|
||||||
const hero = usePageHero('services')
|
const heading = useSectionHeading('process')
|
||||||
const heading = useSectionHeading('process')
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHero title={hero.title} lead={hero.lead} image={hero.image} />
|
<PageHero pageKey="services" />
|
||||||
|
|
||||||
<Services />
|
<Services />
|
||||||
|
|
||||||
@@ -24,13 +24,16 @@ export function ServicesPage() {
|
|||||||
<div className="mt-14 grid gap-px bg-ivory/10 md:grid-cols-2 lg:grid-cols-4">
|
<div className="mt-14 grid gap-px bg-ivory/10 md:grid-cols-2 lg:grid-cols-4">
|
||||||
{process_steps.map((item, i) => (
|
{process_steps.map((item, i) => (
|
||||||
<Reveal key={item.id} delay={0.06 * i} className="h-full">
|
<Reveal key={item.id} delay={0.06 * i} className="h-full">
|
||||||
|
<EditableItem resource="process_steps" item={item} className="h-full">
|
||||||
<div className="flex h-full flex-col justify-center bg-char px-6 py-14 text-center">
|
<div className="flex h-full flex-col justify-center bg-char px-6 py-14 text-center">
|
||||||
<p className="text-4xl font-bold text-copper">{item.step}</p>
|
<p className="text-4xl font-bold text-copper">{item.step}</p>
|
||||||
<h3 className="mt-6 text-xl font-bold text-ivory">{item.title}</h3>
|
<h3 className="mt-6 text-xl font-bold text-ivory">{item.title}</h3>
|
||||||
<p className="mt-4 text-base font-light leading-9 text-ivory/60">{item.desc}</p>
|
<p className="mt-4 text-base font-light leading-9 text-ivory/60">{item.desc}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</EditableItem>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
|
<AddItemButton resource="process_steps" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user