Align home hero API and CMS hero editor

This commit is contained in:
Tống Thành Đạt
2026-04-09 22:03:00 +07:00
parent 4345b75b03
commit ed09c7fa89
2 changed files with 111 additions and 12 deletions

View File

@@ -10,6 +10,7 @@ const AUDIT_ACTIONS = require("../constants/auditAction");
// Các hàm hỗ trợ // Các hàm hỗ trợ
const getHomeDoc = async () => Home.findOne().sort({ updatedAt: -1 }); const getHomeDoc = async () => Home.findOne().sort({ updatedAt: -1 });
const getAllHomeDocs = async () => Home.find().sort({ updatedAt: -1 });
const getHomeData = async () => (await getHomeDoc())?.toObject() || {}; const getHomeData = async () => (await getHomeDoc())?.toObject() || {};
const getOrCreateHomeDoc = async () => { const getOrCreateHomeDoc = async () => {
let doc = await getHomeDoc(); let doc = await getHomeDoc();
@@ -21,6 +22,77 @@ const getOrCreateHomeDoc = async () => {
return doc; return doc;
}; };
const hasMeaningfulValue = (value) =>
typeof value === "string" && value.trim().length > 0;
const getMeaningfulHeroSlides = (hero = {}) => {
if (!Array.isArray(hero.slides)) return [];
return hero.slides.filter((slide = {}) => {
return (
hasMeaningfulValue(slide.title) ||
hasMeaningfulValue(slide.subtitle) ||
hasMeaningfulValue(slide.description) ||
hasMeaningfulValue(slide.heroImage) ||
hasMeaningfulValue(slide.videoUrl) ||
hasMeaningfulValue(slide.primaryButton?.label) ||
hasMeaningfulValue(slide.primaryButton?.href) ||
hasMeaningfulValue(slide.secondaryButton?.label) ||
hasMeaningfulValue(slide.secondaryButton?.href)
);
});
};
const scoreHeroSlides = (slides = []) =>
slides.reduce((score, slide = {}) => {
return (
score +
(slide.title || "").trim().length * 3 +
(slide.subtitle || "").trim().length +
(slide.description || "").trim().length * 2 +
(slide.primaryButton?.label || "").trim().length +
(slide.primaryButton?.href || "").trim().length +
(slide.secondaryButton?.label || "").trim().length +
(slide.secondaryButton?.href || "").trim().length +
(hasMeaningfulValue(slide.heroImage) ? 20 : 0) +
(hasMeaningfulValue(slide.videoUrl) ? 12 : 0)
);
}, 0);
const scoreHeroData = (hero = {}) => {
const slides = getMeaningfulHeroSlides(hero);
if (slides.length > 0) {
return scoreHeroSlides(slides) + slides.length * 30;
}
return (
(hero.title || "").trim().length * 3 +
(hero.subtitle || "").trim().length +
(hero.description || "").trim().length * 2 +
(hero.primaryButton?.label || "").trim().length +
(hero.primaryButton?.href || "").trim().length +
(hero.secondaryButton?.label || "").trim().length +
(hero.secondaryButton?.href || "").trim().length +
(hasMeaningfulValue(hero.heroImage) ? 20 : 0) +
(hasMeaningfulValue(hero.videoUrl) ? 12 : 0)
);
};
const getPreferredHeroData = (docs = []) => {
const heroes = docs
.map((doc) => doc?.hero)
.filter(Boolean);
if (!heroes.length) return {};
return heroes.reduce((bestHero, currentHero) => {
return scoreHeroData(currentHero) > scoreHeroData(bestHero)
? currentHero
: bestHero;
}, heroes[0]);
};
const normalizeStoredImagePath = (imagePath) => { const normalizeStoredImagePath = (imagePath) => {
if (!imagePath || typeof imagePath !== "string") return ""; if (!imagePath || typeof imagePath !== "string") return "";
@@ -373,9 +445,14 @@ exports.apiGetBlogs = async (req, res) => {
}; };
exports.api = async (req, res) => { exports.api = async (req, res) => {
try { try {
let data = await getHomeData(); const docs = await getAllHomeDocs();
let data = docs[0]?.toObject() || {};
const baseUrl = `${req.protocol}://${req.get("host")}`; const baseUrl = `${req.protocol}://${req.get("host")}`;
if (docs.length > 1) {
data.hero = getPreferredHeroData(docs.map((doc) => doc.toObject()));
}
// === Xử lý Blog Preview động === // === Xử lý Blog Preview động ===
const blogPreview = data.blogPreview || {}; const blogPreview = data.blogPreview || {};
let blogs = []; let blogs = [];

View File

@@ -1,21 +1,30 @@
<!-- Hero Tab --> <!-- Hero Tab -->
<div class="tab-pane fade show active" id="hero" role="tabpanel"> <div class="tab-pane fade show active" id="hero" role="tabpanel">
<div class="row g-4"> <div class="row g-4">
<!-- Background Image (section-level) -->
<div class="col-md-12"> <div class="col-md-12">
<div class="card border shadow-sm mb-3"> <div class="card border shadow-sm mb-3">
<div class="card-header bg-white d-flex justify-content-between align-items-center"> <div class="card-header bg-white d-flex justify-content-between align-items-center">
<h6 class="mb-0"> <h6 class="mb-0">
<i class="fas fa-image me-2"></i>Hero Background <i class="fas fa-layer-group me-2"></i>Hero Carousel Setup
</h6> </h6>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row g-3"> <div class="alert alert-light border mb-3">
<div class="col-md-6"> <div class="fw-semibold mb-1">Current homepage hero behavior</div>
<label class="form-label fw-medium">Background Image</label> <div class="small text-muted">
Mỗi slide dùng ảnh riêng làm nền full-width. Title, description và 2 button chỉ là lớp overlay.
Trường video hiện không còn được hiển thị ở frontend.
</div>
</div>
<div class="row g-3 align-items-start">
<div class="col-lg-6">
<label class="form-label fw-medium">Fallback Background Image</label>
<small class="text-muted d-block mb-1">
Tùy chọn dự phòng. Homepage hiện ưu tiên ảnh của từng slide.
</small>
<div class="input-group mb-2"> <div class="input-group mb-2">
<input type="text" class="form-control" id="heroBackgroundImage" <input type="text" class="form-control" id="heroBackgroundImage"
value="<%= data.hero?.backgroundImage || '' %>" placeholder="/assets/img/home-1/hero/bg.jpg" /> value="<%= data.hero?.backgroundImage || '' %>" placeholder="/uploads/home/hero-fallback.jpg" />
<button type="button" class="btn btn-outline-primary btn-upload-image" <button type="button" class="btn btn-outline-primary btn-upload-image"
data-target-input="heroBackgroundImage" data-image-type="home"> data-target-input="heroBackgroundImage" data-image-type="home">
<i class="fas fa-upload me-1"></i>Upload <i class="fas fa-upload me-1"></i>Upload
@@ -28,6 +37,17 @@
</div> </div>
<% } %> <% } %>
</div> </div>
<div class="col-lg-6">
<div class="border rounded p-3 h-100 bg-light-subtle">
<div class="fw-semibold mb-2">Recommended content structure</div>
<ul class="small text-muted mb-0 ps-3">
<li>Dùng ảnh slide tỉ lệ ngang, ưu tiên ảnh lớn để fit container.</li>
<li>Title ngắn 2-4 dòng để không tràn trên mobile.</li>
<li>Description giữ ở mức 1-3 câu ngắn.</li>
<li>Hai nút nên dùng link nội bộ như <code>/contact</code>.</li>
</ul>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -79,6 +99,7 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Subtitle</label> <label class="form-label fw-medium">Subtitle</label>
<small class="text-muted d-block mb-1">Hiện không render ngoài frontend, chỉ giữ để tương thích dữ liệu cũ.</small>
<input type="text" class="form-control" id="heroSlide_<%= index %>_subtitle" <input type="text" class="form-control" id="heroSlide_<%= index %>_subtitle"
value="<%= slide.subtitle || '' %>" placeholder="e.g., Global Education Simplified" /> value="<%= slide.subtitle || '' %>" placeholder="e.g., Global Education Simplified" />
</div> </div>
@@ -88,11 +109,11 @@
placeholder="Enter hero description"><%= slide.description || '' %></textarea> placeholder="Enter hero description"><%= slide.description || '' %></textarea>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Hero Image</label> <label class="form-label fw-medium">Slide Background Image</label>
<small class="text-muted d-block mb-1">Recommended size: 893x848px</small> <small class="text-muted d-block mb-1">Ảnh này đang được dùng làm nền full hero. Khuyến nghị ảnh ngang lớn hoặc GIF nếu cần.</small>
<div class="input-group mb-2"> <div class="input-group mb-2">
<input type="text" class="form-control" id="heroSlide_<%= index %>_heroImage" <input type="text" class="form-control" id="heroSlide_<%= index %>_heroImage"
value="<%= slide.heroImage || '' %>" placeholder="/assets/img/home-1/hero/man.png" /> value="<%= slide.heroImage || '' %>" placeholder="/uploads/home/hero-slide-01.jpg" />
<button type="button" class="btn btn-outline-primary btn-upload-image" <button type="button" class="btn btn-outline-primary btn-upload-image"
data-target-input="heroSlide_<%= index %>_heroImage" data-image-type="home"> data-target-input="heroSlide_<%= index %>_heroImage" data-image-type="home">
<i class="fas fa-upload me-1"></i>Upload <i class="fas fa-upload me-1"></i>Upload
@@ -107,8 +128,9 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-medium">Video URL</label> <label class="form-label fw-medium">Video URL</label>
<small class="text-muted d-block mb-1">Frontend hiện không render video trong hero. Giữ lại chỉ để tránh mất dữ liệu cũ.</small>
<input type="text" class="form-control" id="heroSlide_<%= index %>_videoUrl" <input type="text" class="form-control" id="heroSlide_<%= index %>_videoUrl"
value="<%= slide.videoUrl || '' %>" placeholder="https://www.youtube.com/watch?v=..." /> value="<%= slide.videoUrl || '' %>" placeholder="Không bắt buộc" />
</div> </div>
<!-- Primary Button --> <!-- Primary Button -->
@@ -309,4 +331,4 @@
// Initial normalization (in case indices rendered from server are not 0..n) // Initial normalization (in case indices rendered from server are not 0..n)
updateLabels(); updateLabels();
}); });
</script> </script>