feat: implement Contact Us page with dynamic database seeding and dedicated view controller logic

This commit is contained in:
2026-07-20 19:29:47 +07:00
parent 27fd9e109d
commit a8220ec02d
147 changed files with 12277 additions and 39 deletions
@@ -0,0 +1,207 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{fragments/layout}">
<head>
<title th:text="${page.title} + ' | S.I.S Cần Thơ'">Liên hệ | S.I.S Cần Thơ</title>
<link rel="stylesheet" th:href="@{/css/bvdhyd.css}">
</head>
<body>
<div layout:fragment="content" id="bvdhyd-contact">
<!-- Hero Section -->
<section class="overflow-hidden relative w-full bg-white xl:h-[580px] md:h-[550px] h-[calc(100svh-122px-56px)]">
<div class="bg-linear-hero absolute inset-x-0 bottom-0 top-[20%] z-10"></div>
<img alt="Bệnh viện" fetchpriority="high" loading="eager" class="absolute inset-0 w-full h-full object-cover max-md:hidden" src="https://bvdaihoc.com.vn/uploads/Demo/to%CC%80a%20nha%CC%80%20tru%CC%9Bo%CC%9B%CC%80ng%20vie%CC%A3%CC%82n%20la%CC%80m%20mo%CC%9B%CC%80.jpg" />
</section>
<!-- Main Content -->
<section class="xl:py-16 md:py-12 py-8 md:scroll-mt-[186px] relative z-20" id="map-section">
<div class="container mx-auto px-4 lg:px-10 space-y-4 md:space-y-6 xl:space-y-8 max-w-7xl">
<h1 th:text="${page.title}" class="text-primary-600 display-7 text-center uppercase">Liên hệ</h1>
<div class="grid grid-cols-12 gap-6">
<!-- Left Sidebar (Branches) -->
<div class="col-span-full lg:col-span-5 xl:col-span-4 rounded-lg pr-2 pl-4 py-4 bg-gray-100">
<div id="branch-list" class="space-y-4 overflow-y-auto h-[500px] pr-2 custom-scrollbar">
<!-- Cards will be dynamically injected here by JS -->
</div>
</div>
<!-- Right Main Map -->
<div class="col-span-full lg:col-span-7 xl:col-span-8">
<div class="relative rounded-lg overview-wrapper-shadow overflow-hidden w-full h-full max-lg:h-[450px] [&>iframe]:w-full [&>iframe]:h-full [&>iframe]:border-0">
<iframe id="map-iframe" src="" width="100%" height="100%" style="border:0; min-height:450px;" allowfullscreen="" loading="lazy" referrerpolicy="no-referrer-when-downgrade"></iframe>
</div>
</div>
</div>
</div>
</section>
<!-- Hidden data -->
<div id="postContentRaw" style="display:none;" th:text="${page.content}"></div>
<script>
document.addEventListener("DOMContentLoaded", function() {
const rawData = document.getElementById("postContentRaw").textContent;
const branchListContainer = document.getElementById("branch-list");
const mapIframe = document.getElementById("map-iframe");
try {
const data = JSON.parse(rawData);
let branches = [];
let currentBranch = null;
if (data.blocks) {
data.blocks.forEach(block => {
if (block.type === "header" && block.data.level === 2) {
if (currentBranch) {
branches.push(currentBranch);
}
currentBranch = {
name: block.data.text,
address: '',
phone: '',
email: '',
website: '',
mapUrl: ''
};
} else if (block.type === "list" && currentBranch) {
block.data.items.forEach(item => {
// Remove html tags if any are embedded in string
const tempDiv = document.createElement("div");
tempDiv.innerHTML = item;
const text = tempDiv.textContent || tempDiv.innerText || "";
if (text.toLowerCase().startsWith('địa chỉ')) currentBranch.address = text.split(':').slice(1).join(':').trim();
else if (text.toLowerCase().startsWith('điện thoại')) currentBranch.phone = text.split(':').slice(1).join(':').trim();
else if (text.toLowerCase().startsWith('email')) currentBranch.email = text.split(':').slice(1).join(':').trim();
else if (text.toLowerCase().startsWith('website')) currentBranch.website = text.split(':').slice(1).join(':').trim();
else if (text.toLowerCase().startsWith('map')) currentBranch.mapUrl = text.split(':').slice(1).join(':').trim();
});
}
});
if (currentBranch) branches.push(currentBranch);
}
if (branches.length > 0) {
let html = '';
branches.forEach((branch, index) => {
const isFirst = index === 0;
// Set initial iframe src
if (isFirst && branch.mapUrl) {
mapIframe.src = branch.mapUrl;
}
const bgClass = isFirst ? 'bg-primary-600' : 'bg-white';
const titleClass = isFirst ? 'text-white' : 'text-primary-600';
const iconClass = isFirst ? 'text-white' : 'text-primary-600';
const textClass = isFirst ? 'text-white' : 'text-gray-900';
html += `
<div class="branch-card rounded-lg overflow-hidden cursor-pointer ${bgClass}" data-map-url="${branch.mapUrl}" onclick="selectBranch(this)">
<div class="xl:p-4 p-3 space-y-2">
<div class="title-1 ${titleClass} branch-title">${branch.name}</div>
${branch.address ? `
<div class="flex items-start gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin size-4 flex-shrink-0 mt-1 branch-icon ${iconClass}"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/></svg>
<a href="${branch.mapUrl}" target="_blank" rel="noopener noreferrer nofollow" class="flex-1 body-2 lg:duration-150 lg:hover:underline underline-offset-4 branch-text ${textClass}">${branch.address}</a>
</div>` : ''}
${branch.phone ? `
<div class="flex items-start gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-phone size-4 flex-shrink-0 mt-1 branch-icon ${iconClass}"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
<a href="tel:${branch.phone.replace(/[^0-9]/g, '')}" class="flex-1 body-2 lg:hover:underline underline-offset-4 lg:duration-150 branch-text ${textClass}">${branch.phone}</a>
</div>` : ''}
${branch.email ? `
<div class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-mail size-4 flex-shrink-0 branch-icon ${iconClass}"><rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
<a href="mailto:${branch.email}" class="flex-1 body-2 lg:hover:underline underline-offset-4 lg:duration-150 branch-text ${textClass}">${branch.email}</a>
</div>` : ''}
${branch.website ? `
<div class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-globe size-4 flex-shrink-0 branch-icon ${iconClass}"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>
<a href="${branch.website}" target="_blank" rel="noopener noreferrer nofollow" class="flex-1 body-2 lg:hover:underline underline-offset-4 lg:duration-150 branch-text ${textClass}">${branch.website}</a>
</div>` : ''}
</div>
</div>
`;
});
branchListContainer.innerHTML = html;
}
} catch(e) {
console.error("Error parsing contact data", e);
}
});
// Global function to handle branch selection
function selectBranch(element) {
// Update active state of all cards
const cards = document.querySelectorAll('.branch-card');
cards.forEach(card => {
// Reset to inactive state
card.className = 'branch-card rounded-lg overflow-hidden cursor-pointer bg-white';
// Update text colors
const title = card.querySelector('.branch-title');
if (title) title.className = 'title-1 text-primary-600 branch-title';
const icons = card.querySelectorAll('.branch-icon');
icons.forEach(icon => {
icon.classList.remove('text-white');
icon.classList.add('text-primary-600');
});
const texts = card.querySelectorAll('.branch-text');
texts.forEach(text => {
text.classList.remove('text-white');
text.classList.add('text-gray-900');
});
});
// Set active state on clicked card
element.className = 'branch-card rounded-lg overflow-hidden cursor-pointer bg-primary-600';
const title = element.querySelector('.branch-title');
if (title) title.className = 'title-1 text-white branch-title';
const icons = element.querySelectorAll('.branch-icon');
icons.forEach(icon => {
icon.classList.remove('text-primary-600');
icon.classList.add('text-white');
});
const texts = element.querySelectorAll('.branch-text');
texts.forEach(text => {
text.classList.remove('text-gray-900');
text.classList.add('text-white');
});
// Update Map URL
const mapUrl = element.getAttribute('data-map-url');
const mapIframe = document.getElementById("map-iframe");
if (mapUrl && mapIframe) {
mapIframe.src = mapUrl;
}
}
</script>
<style>
/* Custom scrollbar for branch list */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
</style>
</div>
</body>
</html>