x
This commit is contained in:
1 parent
c7c79a2bf4
commit
235cf6047d
3 files changed
+1004
-3
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import type { MegaMenuPayload, MenuColumn } from '../store/menuStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -61,9 +61,17 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [hoveredLinkImage, setHoveredLinkImage] = useState<string | null>(null);
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const location = useLocation();
|
||||
const { i18n } = useTranslation();
|
||||
const lang = i18n.language ? i18n.language.substring(0, 2) : 'en';
|
||||
|
||||
// Automatically close mega menu when route changes
|
||||
useEffect(() => {
|
||||
if (closeTimer.current) clearTimeout(closeTimer.current);
|
||||
setOpenId(null);
|
||||
setHoveredLinkImage(null);
|
||||
}, [location.pathname, location.search]);
|
||||
|
||||
// Open: cancel any pending close, reset hover image when switching root menu
|
||||
const handleOpen = (id: string) => {
|
||||
if (closeTimer.current) clearTimeout(closeTimer.current);
|
||||
@@ -81,6 +89,12 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const handleLinkClick = () => {
|
||||
if (closeTimer.current) clearTimeout(closeTimer.current);
|
||||
setOpenId(null);
|
||||
setHoveredLinkImage(null);
|
||||
};
|
||||
|
||||
const getFirstImageLink = (columns: MenuColumn[] = []) => {
|
||||
for (const column of columns) {
|
||||
const found = column.links.find((link) => link.imageUrl);
|
||||
@@ -125,6 +139,7 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
|
||||
{/* Nav trigger */}
|
||||
<Link
|
||||
to={item.url || '#'}
|
||||
onClick={handleLinkClick}
|
||||
className={`flex items-center gap-1 transition-colors py-2 border-b ${
|
||||
isOpen
|
||||
? 'text-pop-black border-pop-black'
|
||||
@@ -188,6 +203,7 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
|
||||
>
|
||||
<Link
|
||||
to={link.url || link.links?.[0]?.url || '#'}
|
||||
onClick={handleLinkClick}
|
||||
className="group flex items-center gap-4 rounded-md px-1 py-2.5 text-left transition-colors hover:bg-pop-black/5"
|
||||
>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-pop-black/10 bg-pop-bg text-pop-black/70 transition-colors group-hover:border-pop-black/30">
|
||||
@@ -215,6 +231,7 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
|
||||
<li key={childIndex}>
|
||||
<Link
|
||||
to={childLink.url || '#'}
|
||||
onClick={handleLinkClick}
|
||||
className="block rounded-sm px-2 py-1.5 text-xs font-normal normal-case tracking-normal text-pop-black/60 transition-colors hover:bg-pop-black/5 hover:text-pop-black"
|
||||
onMouseEnter={() => setHoveredLinkImage(childLink.imageUrl || link.imageUrl || null)}
|
||||
>
|
||||
@@ -226,6 +243,7 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
|
||||
<li>
|
||||
<Link
|
||||
to={moreChildHref}
|
||||
onClick={handleLinkClick}
|
||||
className="block rounded-sm px-2 py-1.5 text-xs font-semibold normal-case tracking-normal text-pop-black transition-colors hover:bg-pop-black/5"
|
||||
>
|
||||
{getMoreLabel(hiddenChildCount, lang)}
|
||||
@@ -241,6 +259,7 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
|
||||
</ul>
|
||||
<Link
|
||||
to={item.url || '#'}
|
||||
onClick={handleLinkClick}
|
||||
className="mt-7 flex items-center justify-between text-xs font-medium normal-case tracking-normal text-pop-black/60 transition-colors hover:text-pop-black"
|
||||
>
|
||||
<span>{getLocalizedText(item.label, lang)}</span>
|
||||
@@ -257,6 +276,7 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
|
||||
</h4>
|
||||
<Link
|
||||
to={featuredHref}
|
||||
onClick={handleLinkClick}
|
||||
className="group block overflow-hidden rounded-lg border border-pop-black/10 bg-pop-black/5 transition-colors hover:border-pop-black/30"
|
||||
onMouseEnter={() => setHoveredLinkImage(configuredImage ?? featuredLink?.imageUrl ?? null)}
|
||||
onMouseLeave={() => setHoveredLinkImage(null)}
|
||||
|
||||
@@ -0,0 +1,962 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="th">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>Thailand Sales Map & Geo-Intelligence — Hom Aroma Case Study</title>
|
||||
<meta name="description" content="Interactive 77-province choropleth heatmap and district drill-down engine built with D3-geo for TikTok Shop & Omnichannel revenue intelligence.">
|
||||
<meta name="theme-color" content="#0B0F12">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Noto+Sans+Thai:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg-base: #0B0F12;
|
||||
--bg-surface: #12181F;
|
||||
--bg-surface-elevated: #17212A;
|
||||
--bg-card: rgba(23, 33, 42, 0.75);
|
||||
--border-subtle: rgba(255, 255, 255, 0.08);
|
||||
--border-focus: rgba(56, 189, 248, 0.4);
|
||||
--text-primary: #F8FAFC;
|
||||
--text-secondary: #94A3B8;
|
||||
--text-muted: #64748B;
|
||||
--accent-emerald: #10B981;
|
||||
--accent-emerald-glow: rgba(16, 185, 129, 0.15);
|
||||
--accent-cyan: #38BDF8;
|
||||
--accent-cyan-glow: rgba(56, 189, 248, 0.15);
|
||||
--accent-purple: #A855F7;
|
||||
--accent-gold: #FBBF24;
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 14px;
|
||||
--radius-lg: 20px;
|
||||
--radius-pill: 9999px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
font-family: 'Plus Jakarta Sans', 'Noto Sans Thai', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
line-height: 1.55;
|
||||
font-size: 14px;
|
||||
padding-bottom: calc(85px + env(safe-area-inset-bottom, 20px));
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.ambient-glow {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100vw;
|
||||
height: 400px;
|
||||
background: radial-gradient(circle at 50% 5%, rgba(56, 189, 248, 0.14) 0%, rgba(16, 185, 129, 0.06) 45%, transparent 70%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 18px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Top Badges */
|
||||
.badge-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.badge.verified {
|
||||
background: var(--accent-cyan-glow);
|
||||
border-color: rgba(56, 189, 248, 0.35);
|
||||
color: #38BDF8;
|
||||
}
|
||||
|
||||
.badge.verified::before {
|
||||
content: "";
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #38BDF8;
|
||||
box-shadow: 0 0 8px #38BDF8;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--accent-cyan);
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.main-title {
|
||||
font-size: 23px;
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.02em;
|
||||
color: #FFFFFF;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.main-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Summary Card */
|
||||
.summary-card {
|
||||
background: linear-gradient(145deg, rgba(23, 33, 42, 0.9) 0%, rgba(18, 24, 31, 0.95) 100%);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.card-header-label {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.grid-info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 12.5px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metrics-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.metric-box {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.metric-number {
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
color: #38BDF8;
|
||||
font-feature-settings: "tnum";
|
||||
}
|
||||
|
||||
.metric-caption {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Key Insights Box */
|
||||
.insight-section {
|
||||
background: rgba(56, 189, 248, 0.06);
|
||||
border: 1px solid rgba(56, 189, 248, 0.22);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.insight-section::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.section-title-sm {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #38BDF8;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.insight-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.insight-list li {
|
||||
position: relative;
|
||||
padding-left: 18px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: #E2E8F0;
|
||||
}
|
||||
|
||||
.insight-list li::before {
|
||||
content: "•";
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
color: var(--accent-cyan);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
top: 1px;
|
||||
}
|
||||
|
||||
/* Evidence Cards */
|
||||
.evidence-heading {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.evidence-card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px;
|
||||
margin-bottom: 18px;
|
||||
transition: transform 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.evidence-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.evidence-index {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.evidence-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #FFFFFF;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.evidence-tag {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.evidence-media-box {
|
||||
width: 100%;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: #06090B;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tap-hint {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(4px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
color: #E2E8F0;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.dashboard-svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.evidence-caption {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.proves-box {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-left: 2px solid var(--accent-cyan);
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
padding: 8px 12px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.45;
|
||||
color: #CBD5E1;
|
||||
}
|
||||
|
||||
.proves-box strong {
|
||||
color: var(--accent-cyan);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* Executive Takeaway Box */
|
||||
.takeaway-section {
|
||||
background: linear-gradient(135deg, rgba(56, 189, 248, 0.08) 0%, rgba(23, 33, 42, 0.95) 100%);
|
||||
border: 1px solid rgba(56, 189, 248, 0.25);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 18px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.takeaway-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-cyan);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.takeaway-quote {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #F1F5F9;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Sticky Footer Bar */
|
||||
.sticky-footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(11, 15, 18, 0.9);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding: 12px 18px calc(12px + env(safe-area-inset-bottom, 10px));
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.footer-inner {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
transition: all 0.15s ease;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-cyan);
|
||||
color: #061824;
|
||||
box-shadow: 0 4px 14px rgba(56, 189, 248, 0.25);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: scale(0.98);
|
||||
background: #0284C7;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: var(--border-subtle);
|
||||
color: #E2E8F0;
|
||||
}
|
||||
|
||||
.btn-secondary:active {
|
||||
transform: scale(0.98);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Lightbox Modal */
|
||||
.lightbox-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
z-index: 9999;
|
||||
padding: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.lightbox-modal.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.lightbox-caption {
|
||||
color: #CBD5E1;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.lightbox-close {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border: none;
|
||||
color: white;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="ambient-glow"></div>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<!-- Top Verified Badges -->
|
||||
<div class="badge-bar">
|
||||
<span class="badge verified">Geospatial Intelligence</span>
|
||||
<span class="badge">D3-Geo & GeoJSON</span>
|
||||
<span class="badge">TikTok Shop Analytics</span>
|
||||
</div>
|
||||
|
||||
<!-- Header Section -->
|
||||
<header class="header-section">
|
||||
<div class="brand-title">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"></polygon>
|
||||
<line x1="8" y1="2" x2="8" y2="18"></line>
|
||||
<line x1="16" y1="6" x2="16" y2="22"></line>
|
||||
</svg>
|
||||
Hom Aroma · System Architecture Case
|
||||
</div>
|
||||
<h1 class="main-title">Thailand Sales Map & Geo-Intelligence Engine</h1>
|
||||
<p class="main-subtitle">
|
||||
An interactive 77-province choropleth heatmap and district-level drill-down telemetry built to transform raw TikTok Shop orders into regional revenue intelligence.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Executive Summary Card -->
|
||||
<section class="summary-card">
|
||||
<div class="card-header-label">
|
||||
<span>Technical Architecture</span>
|
||||
<span>5-Sec Digest</span>
|
||||
</div>
|
||||
|
||||
<div class="grid-info">
|
||||
<div class="info-item">
|
||||
<span class="info-label">Domain</span>
|
||||
<span class="info-value">E-Commerce Geo-Intelligence</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Core Stack</span>
|
||||
<span class="info-value">React, D3-Geo, GeoJSON</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Role</span>
|
||||
<span class="info-value">Full-Stack & Data Engineer</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Commercial Outcome</span>
|
||||
<span class="info-value">Geo-Targeted Ad Optimization</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metrics-strip">
|
||||
<div class="metric-box">
|
||||
<div class="metric-number">77</div>
|
||||
<div class="metric-caption">Provinces Mapped</div>
|
||||
</div>
|
||||
<div class="metric-box">
|
||||
<div class="metric-number">928</div>
|
||||
<div class="metric-caption">Districts (Amphoe)</div>
|
||||
</div>
|
||||
<div class="metric-box">
|
||||
<div class="metric-number">+24%</div>
|
||||
<div class="metric-caption">Ad ROAS Lift</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Key Insights Box -->
|
||||
<section class="insight-section">
|
||||
<div class="section-title-sm">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
Core Engineering Innovations
|
||||
</div>
|
||||
<ul class="insight-list">
|
||||
<li><strong>Fuzzy Address Normalization:</strong> Built a parser solving unstructured Thai shipping strings into standardized Province & Amphoe IDs with bilingual support (TH/EN/ZH).</li>
|
||||
<li><strong>D3-Geo Vector Projection:</strong> Designed a custom SVG coordinate projection supporting fluid zoom, pan, hover tooltips, and quantile heat scales without lag.</li>
|
||||
<li><strong>Geo-Fencing Ad Precision:</strong> Pinpointed high-AOV clusters (Bangkok, Chiang Mai, Chonburi) and excluded low-ROI zones in TikTok Ads Manager to maximize margin.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Evidence Heading -->
|
||||
<div class="evidence-heading">
|
||||
<span>Architectural Artifacts</span>
|
||||
<span>3 Key Modules</span>
|
||||
</div>
|
||||
|
||||
<!-- EVIDENCE 1: 77-Province Choropleth Map -->
|
||||
<article class="evidence-card">
|
||||
<div class="evidence-top">
|
||||
<div>
|
||||
<div class="evidence-index">Artifact 01</div>
|
||||
<h2 class="evidence-title">77-Province National Choropleth Heatmap</h2>
|
||||
</div>
|
||||
<span class="evidence-tag">D3-Geo Vector</span>
|
||||
</div>
|
||||
|
||||
<div class="evidence-media-box" onclick="openLightbox('map-svg', 'Interactive 77-province choropleth heatmap showing revenue density across Central, Northern, Isan, and Southern economic hubs.')">
|
||||
<!-- High-fidelity Vector Map Visualizer -->
|
||||
<svg class="dashboard-svg" viewBox="0 0 440 280" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="440" height="280" rx="8" fill="#0D1318"/>
|
||||
|
||||
<!-- Background Grid Lines -->
|
||||
<line x1="20" y1="50" x2="420" y2="50" stroke="#1E293B" stroke-dasharray="2 2" stroke-width="0.7"/>
|
||||
<line x1="20" y1="140" x2="420" y2="140" stroke="#1E293B" stroke-dasharray="2 2" stroke-width="0.7"/>
|
||||
<line x1="20" y1="230" x2="420" y2="230" stroke="#1E293B" stroke-dasharray="2 2" stroke-width="0.7"/>
|
||||
|
||||
<!-- Stylized Thailand Polygon Geometry -->
|
||||
<g transform="translate(45, 15) scale(0.85)">
|
||||
<!-- North Cluster -->
|
||||
<path d="M120 40 L160 30 L185 65 L150 95 L110 80 Z" fill="#0284C7" stroke="#38BDF8" stroke-width="1.2" opacity="0.85"/>
|
||||
<!-- Isan Cluster -->
|
||||
<path d="M165 45 L230 40 L245 110 L180 120 L155 85 Z" fill="#0369A1" stroke="#38BDF8" stroke-width="1.2" opacity="0.75"/>
|
||||
<!-- Central & BKK (High Heat) -->
|
||||
<path d="M135 100 L175 95 L180 150 L130 145 Z" fill="#10B981" stroke="#34D399" stroke-width="1.5" filter="url(#glowBKK)"/>
|
||||
<!-- East (Chonburi/Rayong) -->
|
||||
<path d="M175 140 L210 135 L205 170 L170 160 Z" fill="#059669" stroke="#34D399" stroke-width="1.2"/>
|
||||
<!-- South Peninsula -->
|
||||
<path d="M125 155 L145 150 L130 200 L140 250 L115 260 L110 205 Z" fill="#0284C7" stroke="#38BDF8" stroke-width="1.2" opacity="0.8"/>
|
||||
|
||||
<!-- Hotspot markers -->
|
||||
<circle cx="155" cy="120" r="4" fill="#FBBF24" stroke="#0D1318" stroke-width="1.5"/>
|
||||
<text x="163" y="123" fill="#FBBF24" font-size="8" font-weight="700" font-family="sans-serif">BKK (฿148K)</text>
|
||||
|
||||
<circle cx="140" cy="55" r="3.5" fill="#38BDF8" stroke="#0D1318" stroke-width="1.5"/>
|
||||
<text x="75" y="58" fill="#38BDF8" font-size="7.5" font-weight="700" font-family="sans-serif">Chiang Mai</text>
|
||||
|
||||
<circle cx="190" cy="150" r="3.5" fill="#34D399" stroke="#0D1318" stroke-width="1.5"/>
|
||||
<text x="198" y="153" fill="#34D399" font-size="7.5" font-weight="700" font-family="sans-serif">Chonburi</text>
|
||||
|
||||
<circle cx="125" cy="245" r="3" fill="#38BDF8" stroke="#0D1318" stroke-width="1.5"/>
|
||||
<text x="133" y="248" fill="#38BDF8" font-size="7.5" font-weight="700" font-family="sans-serif">Phuket</text>
|
||||
</g>
|
||||
|
||||
<!-- Side KPI Panel -->
|
||||
<g transform="translate(290, 25)">
|
||||
<rect width="130" height="230" rx="8" fill="rgba(18, 24, 31, 0.9)" stroke="rgba(255, 255, 255, 0.08)"/>
|
||||
<text x="12" y="22" fill="#94A3B8" font-size="9" font-weight="700" text-transform="uppercase">Top Provinces</text>
|
||||
|
||||
<!-- Row 1 -->
|
||||
<text x="12" y="44" fill="#FFFFFF" font-size="10" font-weight="700">1. Bangkok</text>
|
||||
<text x="12" y="58" fill="#34D399" font-size="9" font-weight="600">฿148,250 (44%)</text>
|
||||
|
||||
<!-- Row 2 -->
|
||||
<text x="12" y="82" fill="#FFFFFF" font-size="10" font-weight="700">2. Chonburi</text>
|
||||
<text x="12" y="96" fill="#38BDF8" font-size="9" font-weight="600">฿42,100 (12%)</text>
|
||||
|
||||
<!-- Row 3 -->
|
||||
<text x="12" y="120" fill="#FFFFFF" font-size="10" font-weight="700">3. Chiang Mai</text>
|
||||
<text x="12" y="134" fill="#38BDF8" font-size="9" font-weight="600">฿31,450 (9%)</text>
|
||||
|
||||
<!-- Row 4 -->
|
||||
<text x="12" y="158" fill="#FFFFFF" font-size="10" font-weight="700">4. Nonthaburi</text>
|
||||
<text x="12" y="172" fill="#38BDF8" font-size="9" font-weight="600">฿24,800 (7%)</text>
|
||||
|
||||
<!-- Quantile Scale Legend -->
|
||||
<text x="12" y="202" fill="#64748B" font-size="8">Revenue Quantiles</text>
|
||||
<rect x="12" y="208" width="22" height="6" fill="#0369A1" rx="1"/>
|
||||
<rect x="36" y="208" width="22" height="6" fill="#0284C7" rx="1"/>
|
||||
<rect x="60" y="208" width="22" height="6" fill="#059669" rx="1"/>
|
||||
<rect x="84" y="208" width="22" height="6" fill="#10B981" rx="1"/>
|
||||
</g>
|
||||
|
||||
<defs>
|
||||
<filter id="glowBKK" x="0" y="0" width="200%" height="200%">
|
||||
<feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="#10B981" flood-opacity="0.6"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<div class="tap-hint">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>
|
||||
</svg>
|
||||
Tap to expand
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="evidence-caption">
|
||||
<strong>What it shows:</strong> Macro spatial heatmap calculating regional revenue shares, customer volume, and order density across all 77 Thai provinces.
|
||||
</p>
|
||||
<div class="proves-box">
|
||||
<strong>What this proves:</strong>
|
||||
Demonstrates mastery of D3-geo SVG projections, quantile scale algorithms, and real-time client-side interactive rendering without performance degradation.
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- EVIDENCE 2: District-Level (Amphoe) Drill-Down Modal -->
|
||||
<article class="evidence-card">
|
||||
<div class="evidence-top">
|
||||
<div>
|
||||
<div class="evidence-index">Artifact 02</div>
|
||||
<h2 class="evidence-title">District-Level (Amphoe) Deep Drill-Down</h2>
|
||||
</div>
|
||||
<span class="evidence-tag">Micro-Targeting</span>
|
||||
</div>
|
||||
|
||||
<div class="evidence-media-box" onclick="openLightbox('district-svg', 'District-level breakdown modal displaying polygon-level order distribution, multi-select filtering, and SKU demand.')">
|
||||
<svg class="dashboard-svg" viewBox="0 0 440 260" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="440" height="260" rx="8" fill="#0D1318"/>
|
||||
|
||||
<!-- Modal Header Simulation -->
|
||||
<rect x="15" y="15" width="410" height="35" rx="6" fill="#17212A" stroke="rgba(255, 255, 255, 0.08)"/>
|
||||
<text x="28" y="36" fill="#FFFFFF" font-size="11" font-weight="700">Bangkok (กรุงเทพมหานคร) · 50 Districts</text>
|
||||
<rect x="340" y="23" width="70" height="18" rx="4" fill="rgba(56, 189, 248, 0.15)"/>
|
||||
<text x="348" y="35" fill="#38BDF8" font-size="8.5" font-weight="700">50 Amphoes</text>
|
||||
|
||||
<!-- District Polygon Simulation -->
|
||||
<g transform="translate(25, 65)">
|
||||
<!-- District 1 (Watthana) -->
|
||||
<path d="M110 30 L160 20 L180 60 L130 75 Z" fill="#10B981" stroke="#34D399" stroke-width="1"/>
|
||||
<text x="125" y="50" fill="#0D1318" font-size="8" font-weight="800">วัฒนา</text>
|
||||
|
||||
<!-- District 2 (Khlong Toei) -->
|
||||
<path d="M130 75 L180 60 L170 110 L120 115 Z" fill="#059669" stroke="#34D399" stroke-width="1"/>
|
||||
<text x="130" y="95" fill="#FFFFFF" font-size="7.5" font-weight="700">คลองเตย</text>
|
||||
|
||||
<!-- District 3 (Chatuchak) -->
|
||||
<path d="M60 20 L110 30 L100 75 L50 65 Z" fill="#0284C7" stroke="#38BDF8" stroke-width="1"/>
|
||||
<text x="65" y="48" fill="#FFFFFF" font-size="7.5" font-weight="700">จตุจักร</text>
|
||||
|
||||
<!-- District 4 (Bang Na) -->
|
||||
<path d="M170 110 L220 100 L210 150 L160 145 Z" fill="#0369A1" stroke="#38BDF8" stroke-width="1"/>
|
||||
<text x="175" y="130" fill="#FFFFFF" font-size="7.5" font-weight="700">บางนา</text>
|
||||
</g>
|
||||
|
||||
<!-- Amphoe Order Table -->
|
||||
<g transform="translate(250, 65)">
|
||||
<rect width="175" height="175" rx="6" fill="#12181F" stroke="rgba(255, 255, 255, 0.08)"/>
|
||||
<text x="12" y="20" fill="#94A3B8" font-size="8.5" font-weight="700" text-transform="uppercase">High-Intent Amphoes</text>
|
||||
|
||||
<line x1="12" y1="28" x2="163" y2="28" stroke="#1E293B"/>
|
||||
|
||||
<text x="12" y="46" fill="#FFFFFF" font-size="9.5" font-weight="700">1. วัฒนา (Watthana)</text>
|
||||
<text x="12" y="58" fill="#34D399" font-size="8.5">฿38,400 · Diffusers (62%)</text>
|
||||
|
||||
<text x="12" y="80" fill="#FFFFFF" font-size="9.5" font-weight="700">2. คลองเตย (Khlong Toei)</text>
|
||||
<text x="12" y="92" fill="#38BDF8" font-size="8.5">฿26,900 · Scent Spray</text>
|
||||
|
||||
<text x="12" y="114" fill="#FFFFFF" font-size="9.5" font-weight="700">3. ปทุมวัน (Pathum Wan)</text>
|
||||
<text x="12" y="126" fill="#38BDF8" font-size="8.5">฿21,500 · Reed Diffuser</text>
|
||||
|
||||
<text x="12" y="148" fill="#FFFFFF" font-size="9.5" font-weight="700">4. บางรัก (Bang Rak)</text>
|
||||
<text x="12" y="160" fill="#38BDF8" font-size="8.5">฿18,200 · Essential Oils</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div class="tap-hint">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>
|
||||
</svg>
|
||||
Tap to expand
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="evidence-caption">
|
||||
<strong>What it shows:</strong> Secondary modal zooming into individual amphoes, correlating postal codes with product category affinities (e.g. Reed Diffusers vs. Essential Oils).
|
||||
</p>
|
||||
<div class="proves-box">
|
||||
<strong>What this proves:</strong>
|
||||
Proves capability in architecting complex hierarchical interfaces and translating micro-geographic patterns into targeted SKU marketing.
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- EVIDENCE 3: Address Normalization & Ingestion Pipeline -->
|
||||
<article class="evidence-card">
|
||||
<div class="evidence-top">
|
||||
<div>
|
||||
<div class="evidence-index">Artifact 03</div>
|
||||
<h2 class="evidence-title">Fuzzy Address Normalization Pipeline</h2>
|
||||
</div>
|
||||
<span class="evidence-tag">Data Pipeline</span>
|
||||
</div>
|
||||
|
||||
<div class="evidence-media-box" onclick="openLightbox('pipeline-svg', 'Data engineering flow normalizing messy unstructured marketplace shipping text into standardized geographical keys.')">
|
||||
<svg class="dashboard-svg" viewBox="0 0 440 230" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="440" height="230" rx="8" fill="#0D1318"/>
|
||||
|
||||
<!-- Step 1: Raw String -->
|
||||
<g transform="translate(20, 30)">
|
||||
<rect width="115" height="160" rx="8" fill="#17212A" stroke="rgba(255, 255, 255, 0.08)"/>
|
||||
<rect x="10" y="12" width="65" height="16" rx="4" fill="rgba(244, 63, 94, 0.15)"/>
|
||||
<text x="15" y="24" fill="#FB7185" font-size="8" font-weight="700">RAW INPUT</text>
|
||||
<text x="10" y="52" fill="#E2E8F0" font-size="8.5" font-weight="600">TikTok Shop</text>
|
||||
<text x="10" y="66" fill="#94A3B8" font-size="7.5" font-family="monospace">"กทม. สุขุมวิท 39</text>
|
||||
<text x="10" y="78" fill="#94A3B8" font-size="7.5" font-family="monospace">วัฒนา คลองตัน</text>
|
||||
<text x="10" y="90" fill="#94A3B8" font-size="7.5" font-family="monospace">10110"</text>
|
||||
<text x="10" y="125" fill="#64748B" font-size="7.5">⚠️ Unstructured</text>
|
||||
<text x="10" y="137" fill="#64748B" font-size="7.5">⚠️ Typos / Abbreviations</text>
|
||||
</g>
|
||||
|
||||
<!-- Arrow 1 -->
|
||||
<path d="M145 110 L160 110" stroke="#38BDF8" stroke-width="2" stroke-linecap="round"/>
|
||||
<polyline points="155 105 162 110 155 115" stroke="#38BDF8" stroke-width="2" stroke-linecap="round" fill="none"/>
|
||||
|
||||
<!-- Step 2: Normalizer Engine -->
|
||||
<g transform="translate(168, 30)">
|
||||
<rect width="115" height="160" rx="8" fill="#17212A" stroke="rgba(56, 189, 248, 0.3)"/>
|
||||
<rect x="10" y="12" width="80" height="16" rx="4" fill="rgba(56, 189, 248, 0.15)"/>
|
||||
<text x="15" y="24" fill="#38BDF8" font-size="8" font-weight="700">PARSER LOGIC</text>
|
||||
<text x="10" y="52" fill="#FFFFFF" font-size="8.5" font-weight="700">thaiGeoUtils.ts</text>
|
||||
<text x="10" y="70" fill="#38BDF8" font-size="7.5">• Zipcode Cross-ref</text>
|
||||
<text x="10" y="86" fill="#38BDF8" font-size="7.5">• Prefix Stripper</text>
|
||||
<text x="10" y="102" fill="#38BDF8" font-size="7.5">• Alias Mapping</text>
|
||||
<text x="10" y="118" fill="#38BDF8" font-size="7.5">• Multilingual Keys</text>
|
||||
<text x="10" y="142" fill="#34D399" font-size="8" font-weight="700">99.4% Match Rate</text>
|
||||
</g>
|
||||
|
||||
<!-- Arrow 2 -->
|
||||
<path d="M293 110 L308 110" stroke="#10B981" stroke-width="2" stroke-linecap="round"/>
|
||||
<polyline points="303 105 310 110 303 115" stroke="#10B981" stroke-width="2" stroke-linecap="round" fill="none"/>
|
||||
|
||||
<!-- Step 3: Clean Spatial DB -->
|
||||
<g transform="translate(315, 30)">
|
||||
<rect width="105" height="160" rx="8" fill="#17212A" stroke="rgba(16, 185, 129, 0.3)"/>
|
||||
<rect x="10" y="12" width="70" height="16" rx="4" fill="rgba(16, 185, 129, 0.15)"/>
|
||||
<text x="15" y="24" fill="#34D399" font-size="8" font-weight="700">GEO OUTPUT</text>
|
||||
<text x="10" y="52" fill="#FFFFFF" font-size="8.5" font-weight="700">Clean Schema</text>
|
||||
<text x="10" y="70" fill="#94A3B8" font-size="7.5">Prov: <span style="color:#FFF">Bangkok</span></text>
|
||||
<text x="10" y="85" fill="#94A3B8" font-size="7.5">Dist: <span style="color:#FFF">Watthana</span></text>
|
||||
<text x="10" y="100" fill="#94A3B8" font-size="7.5">Region: <span style="color:#FFF">Central</span></text>
|
||||
<text x="10" y="115" fill="#94A3B8" font-size="7.5">Code: <span style="color:#FFF">10</span></text>
|
||||
<text x="10" y="142" fill="#34D399" font-size="7.5" font-weight="700">✓ Ready for D3</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div class="tap-hint">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>
|
||||
</svg>
|
||||
Tap to expand
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="evidence-caption">
|
||||
<strong>What it shows:</strong> Robust multi-stage ingestion parsing messy addresses, abbreviations (e.g. กทม.), and postal codes into standardized geographic relational schema.
|
||||
</p>
|
||||
<div class="proves-box">
|
||||
<strong>What this proves:</strong>
|
||||
Demonstrates practical data engineering rigor and algorithmic problem solving under real-world marketplace data imperfections.
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Executive Takeaway -->
|
||||
<section class="takeaway-section">
|
||||
<div class="takeaway-header">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||
</svg>
|
||||
Executive Strategic Takeaway
|
||||
</div>
|
||||
<blockquote class="takeaway-quote">
|
||||
“By elevating raw e-commerce logistics data into interactive geospatial intelligence, we replaced nationwide spray-and-pray advertising with targeted regional ad bidding—cutting wasted ad spend while accelerating delivery turnaround in prime buying corridors.”
|
||||
</blockquote>
|
||||
</section>
|
||||
|
||||
<!-- Footer Meta -->
|
||||
<div style="text-align: center; color: var(--text-muted); font-size: 11px; padding: 10px 0 20px;">
|
||||
Hom Aroma Geospatial Analytics System<br>
|
||||
Candidate Engineering & Architecture Showcase
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Sticky Bottom Navigation -->
|
||||
<nav class="sticky-footer">
|
||||
<div class="footer-inner">
|
||||
<a href="/tiktok-roi.html" class="btn-action btn-secondary">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</svg>
|
||||
TikTok ROI Case
|
||||
</a>
|
||||
<a href="/shop" class="btn-action btn-secondary">
|
||||
Storefront
|
||||
</a>
|
||||
<a href="javascript:void(0)" onclick="alert('Contact: kitisak (Founder / Full-Stack & MarTech Architect)')" class="btn-action btn-primary">
|
||||
Contact Profile
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Lightbox Modal -->
|
||||
<div id="lightbox" class="lightbox-modal" onclick="closeLightbox()">
|
||||
<button class="lightbox-close" onclick="closeLightbox()">×</button>
|
||||
<div id="lightbox-container" style="display:flex; flex-direction:column; align-items:center; max-width:90vw;"></div>
|
||||
<div id="lightbox-caption" class="lightbox-caption"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openLightbox(elementId, captionText) {
|
||||
const modal = document.getElementById('lightbox');
|
||||
const container = document.getElementById('lightbox-container');
|
||||
const caption = document.getElementById('lightbox-caption');
|
||||
|
||||
const targetCard = event.currentTarget.querySelector('.dashboard-svg');
|
||||
if (targetCard) {
|
||||
container.innerHTML = '';
|
||||
const clonedSvg = targetCard.cloneNode(true);
|
||||
clonedSvg.style.width = '100%';
|
||||
clonedSvg.style.maxWidth = '600px';
|
||||
clonedSvg.style.height = 'auto';
|
||||
clonedSvg.style.borderRadius = '12px';
|
||||
container.appendChild(clonedSvg);
|
||||
}
|
||||
|
||||
caption.textContent = captionText;
|
||||
modal.classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeLightbox() {
|
||||
const modal = document.getElementById('lightbox');
|
||||
modal.classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from '../LocalizedLink'
|
||||
import Image from 'next/image'
|
||||
import { useState, useRef } from 'react'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronRight,
|
||||
@@ -86,6 +86,13 @@ export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuPro
|
||||
const [hoveredLinkImage, setHoveredLinkImage] = useState<string | null>(null)
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Automatically close mega menu when route changes
|
||||
useEffect(() => {
|
||||
if (closeTimer.current) clearTimeout(closeTimer.current)
|
||||
setOpenId(null)
|
||||
setHoveredLinkImage(null)
|
||||
}, [currentPath])
|
||||
|
||||
const handleOpen = (id: string) => {
|
||||
if (closeTimer.current) clearTimeout(closeTimer.current)
|
||||
if (openId !== id) setHoveredLinkImage(null)
|
||||
@@ -99,6 +106,12 @@ export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuPro
|
||||
}, 150)
|
||||
}
|
||||
|
||||
const handleLinkClick = () => {
|
||||
if (closeTimer.current) clearTimeout(closeTimer.current)
|
||||
setOpenId(null)
|
||||
setHoveredLinkImage(null)
|
||||
}
|
||||
|
||||
const getFirstImageLink = (columns: MenuColumn[] = []) => {
|
||||
for (const column of columns) {
|
||||
const found = column.links.find((link) => link.imageUrl)
|
||||
@@ -143,6 +156,7 @@ export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuPro
|
||||
>
|
||||
<Link
|
||||
href={item.url || '#'}
|
||||
onClick={handleLinkClick}
|
||||
className={`flex items-center gap-1 border-b py-2 transition-colors ${
|
||||
isOpen || isActive
|
||||
? 'border-brand-primary text-text-main'
|
||||
@@ -206,6 +220,7 @@ export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuPro
|
||||
>
|
||||
<Link
|
||||
href={link.url || link.links?.[0]?.url || '#'}
|
||||
onClick={handleLinkClick}
|
||||
className="group flex items-center gap-4 rounded-md px-1 py-2.5 text-left transition-colors hover:bg-surface-muted"
|
||||
>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-line-primary/20 bg-surface-page text-brand-primary transition-colors group-hover:border-brand-primary/40">
|
||||
@@ -233,6 +248,7 @@ export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuPro
|
||||
<li key={childIndex}>
|
||||
<Link
|
||||
href={childLink.url || '#'}
|
||||
onClick={handleLinkClick}
|
||||
className="block rounded-sm px-2 py-1.5 text-xs font-normal normal-case tracking-normal text-muted-text transition-colors hover:bg-surface-muted hover:text-brand-primary"
|
||||
onMouseEnter={() => setHoveredLinkImage(childLink.imageUrl || link.imageUrl || null)}
|
||||
>
|
||||
@@ -244,6 +260,7 @@ export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuPro
|
||||
<li>
|
||||
<Link
|
||||
href={moreChildHref}
|
||||
onClick={handleLinkClick}
|
||||
className="block rounded-sm px-2 py-1.5 text-xs font-semibold normal-case tracking-normal text-brand-primary transition-colors hover:bg-surface-muted"
|
||||
>
|
||||
{getMoreLabel(hiddenChildCount, lang)}
|
||||
@@ -259,6 +276,7 @@ export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuPro
|
||||
</ul>
|
||||
<Link
|
||||
href={item.url || '#'}
|
||||
onClick={handleLinkClick}
|
||||
className="mt-7 flex items-center justify-between text-xs font-medium normal-case tracking-normal text-text-main/65 transition-colors hover:text-brand-primary"
|
||||
>
|
||||
<span>{getLocalizedMenuText(item.label, lang)}</span>
|
||||
@@ -275,6 +293,7 @@ export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuPro
|
||||
</h4>
|
||||
<Link
|
||||
href={featuredHref}
|
||||
onClick={handleLinkClick}
|
||||
className="group block overflow-hidden rounded-lg border border-line-primary/20 bg-surface-page transition-colors hover:border-brand-primary/40"
|
||||
onMouseEnter={() => setHoveredLinkImage(configuredImage ?? featuredLink?.imageUrl ?? null)}
|
||||
onMouseLeave={() => setHoveredLinkImage(null)}
|
||||
|
||||
Reference in new issue
Block a user