根據香港法律,不得在業務過程中,向未成年人售賣或供應令人醺醉的酒類。
Under the law of Hong Kong, intoxicating liquor must not be sold or supplied to a minor in the course of business.

Pascal Clement Meursault Blanc 2022

FRW0242

6 items left

Pascal Clément 生於布爾岡,在頂級的酒莊擁有超過 20 年的工作經驗,其中包括備受推崇、位於 Meursault (䁥稱「貓鬚」) 的 Domaine Coche-Dury。

Pascal 於 2012 年創立了自己的酒莊,秉持一個理念:追求葡萄酒品質,並尊重風土。他與理念相同的葡萄農合作無間,當中大部份已轉向有機種植,有些轉向生物動力農法。

Pascal 的釀酒方式極為低干預,80% 出品為白酒。

Pascal Clément’s 的 Meursault 葡萄園位於約海拔 260 米,面向東南,種植於侏羅紀時期的泥灰質與含鎂石灰岩土壤上。自 1098 年起,熙篤會修士改良了這片土地,使其融合卡洛維期石灰岩與阿爾戈維期泥灰。Meursault 村以優雅的建築與標誌性的 53 米高尖塔聞名,並於 1937 年獲得 AOC 認證,奠定其頂級白酒產區的地位。

產區保護條例:
布爾岡 Cote de Beaune, Meursault Village

產地:
法國布爾岡 (Burgundy)

種類:
白葡萄酒

葡萄品種:
Chardonnay 

酒精度:
12.70%

容量:
750毫升

釀造:
於橡木桶內用天然酵母發酵,於木桶陳年 11 個月。

品酒筆記:
清澈明亮,散發成熟葡萄、烤杏仁、榛子,以及花香與礦物氣息。帶有牛油、蜂蜜與柑橘的細緻香氣。口感飽滿豐富,在柔滑與清新之間取得平衡。餘韻悠長、結構紮實,具出色的陳年潛力。


Born into a family of winemakers in Burgundy, Pascal Clément brings over 20 years of experience from prestigious domaines, including the renowned Domaine Coche-Dury in Meursault.

In 2012, he founded his own winery with a clear philosophy: crafting high quality wines with deep respect for the terroir. Collaborating with long-established winegrower friends who share the same philosophy. Most of them have turned to organic farming, some even to biodynamics.

Pascal embraces a low-intervention approach, with 80% of his production dedicated to white wine. 

Pascal Clément’s Meursault vineyards lie at ~260m with east-south exposure, rooted in marly and magnesian limestone from Jurassic strata. Improved by Cîteaux monks since 1098, the soils blend Callovian limestone and Argovian marl. The village, known for its elegant architecture and iconic 53m steeple, earned AOC status in 1937, cementing its reputation for exceptional white wines.

Appellation:
Meursault Village, Cote de Beaune, Bourgogne

Country of Origin:
Burgundy, France

Type:
White/Blanc (Still)

Grape Variety:
Chardonnay 

ABV:
12.70%

Volume:
750mL

Vinification: 
Fermented with natural yeasts and aging in barrels for 11 months.  

Tasting Notes:
Clear and brilliant, with a bouquet of ripe grapes, grilled almonds, hazelnuts, and floral-mineral notes. Hints of butter, honey, and citrus. Full-bodied and rich on the palate, balancing freshness and silkiness. Lingering, structured and age-worthy.


(function () { var CHAT_IDS = ["sens-ai-root", "sens-ai-panel", "sens-ai-launcher", "sens-ai-toast"]; function dedupeChatWidgets() { CHAT_IDS.forEach(function (id) { var nodes = document.querySelectorAll("#" + id); for (var i = nodes.length - 1; i > 0; i--) { nodes[i].remove(); } }); } function mountChatToBody() { CHAT_IDS.forEach(function (id) { var el = document.getElementById(id); if (el && el.parentNode !== document.body) { document.body.appendChild(el); } }); } dedupeChatWidgets(); if (window.__SENS_AI_BOOTED__) { return; } mountChatToBody(); var rootMount = document.getElementById("sens-ai-root"); var CONFIG = { apiUrl: "/apps/sens-ai/chat", shopUrl: (rootMount && rootMount.getAttribute("data-shop-url")) || window.location.origin }; var STORAGE_LAYOUT = "sens_ai_panel_layout"; var STORAGE_MIN = "sens_ai_panel_minimized"; var STORAGE_SESSION = "sens_ai_chat_session"; var STORAGE_NAV_OPEN = "sens_ai_open_after_nav"; var DEFAULT_W = 420; var DEFAULT_H = 600; var MIN_W = 320; var MIN_H = 360; var MINIMIZED_H = 52; var MARGIN = 16; var MOBILE = 768; var MOBILE_DOCK_H_RATIO = 0.82; var panel = document.getElementById("sens-ai-panel"); var launcher = document.getElementById("sens-ai-launcher"); var dragHandle = document.getElementById("sens-ai-drag-handle"); var newChatBtn = document.getElementById("sens-ai-new-chat"); var maximizeBtn = document.getElementById("sens-ai-maximize"); var minimizeBtn = document.getElementById("sens-ai-minimize"); var messagesEl = document.getElementById("sens-ai-messages"); var formEl = document.getElementById("sens-ai-form"); var inputEl = document.getElementById("sens-ai-input"); var sendEl = document.getElementById("sens-ai-send"); var toastEl = document.getElementById("sens-ai-toast"); if (!panel || !launcher) return; window.__SENS_AI_BOOTED__ = true; var layout = null; var expandedLayout = null; var preMaximizeLayout = null; var isMinimized = false; var isMaximized = false; var isDragging = false; var isResizing = false; var dragState = null; var resizeState = null; var headerClickStart = null; var HEADER_CLICK_THRESHOLD = 6; var history = []; var busy = false; var WELCOME_MESSAGE = "Welcome — I'm your SENS cellar advisor. Tell me what you're eating, celebrating, or craving, and I'll suggest bottles from our shop."; var BUTLER_AVATAR_LABEL = "Your Wine Butler"; var BUTLER_AVATAR_SIZE = 40; var BUTLER_AVATAR_SVG = '"; function getButlerAvatarUrl() { if (!rootMount) return ""; return String(rootMount.getAttribute("data-butler-avatar-url") || "").trim(); } function getButlerAdvisorName() { if (!rootMount) return "Mirai みらい"; var name = String(rootMount.getAttribute("data-butler-advisor-name") || "Mirai みらい").trim(); return name || "Mirai みらい"; } function applyButlerAvatarContent(avatar) { var url = getButlerAvatarUrl(); avatar.classList.toggle("sens-ai-msg__avatar--image", !!url); avatar.textContent = ""; if (url) { var img = document.createElement("img"); img.src = url; img.alt = ""; img.width = BUTLER_AVATAR_SIZE; img.height = BUTLER_AVATAR_SIZE; img.loading = "lazy"; img.decoding = "async"; avatar.appendChild(img); return; } avatar.innerHTML = BUTLER_AVATAR_SVG; } function createButlerSender() { var advisorName = getButlerAdvisorName(); var sender = document.createElement("div"); sender.className = "sens-ai-msg__sender"; var avatar = document.createElement("div"); avatar.className = "sens-ai-msg__avatar"; avatar.setAttribute("role", "img"); avatar.setAttribute("aria-label", advisorName + ", " + BUTLER_AVATAR_LABEL); avatar.setAttribute("title", advisorName); applyButlerAvatarContent(avatar); var name = document.createElement("p"); name.className = "sens-ai-msg__sender-name"; name.textContent = advisorName; sender.appendChild(avatar); sender.appendChild(name); return sender; } function ensureBotMessageAvatars() { messagesEl.querySelectorAll(".sens-ai-msg--bot").forEach(function (msg) { var sender = msg.querySelector(":scope > .sens-ai-msg__sender"); if (sender) { var avatar = sender.querySelector(".sens-ai-msg__avatar"); if (avatar) applyButlerAvatarContent(avatar); var nameEl = sender.querySelector(".sens-ai-msg__sender-name"); if (nameEl) nameEl.textContent = getButlerAdvisorName(); return; } var bareAvatar = msg.querySelector(":scope > .sens-ai-msg__avatar"); if (bareAvatar) { bareAvatar.replaceWith(createButlerSender()); return; } msg.insertBefore(createButlerSender(), msg.firstChild); }); } function isMobile() { return window.innerWidth <= MOBILE; } function mobileDockLayout() { var w = window.innerWidth; var ratio = isMaximized ? 1 : MOBILE_DOCK_H_RATIO; var h = Math.round(Math.min(window.innerHeight * ratio, window.innerHeight - (isMaximized ? 0 : 48))); return { x: 0, y: Math.max(0, window.innerHeight - h), width: w, height: h }; } function maximizeLayout() { if (isMobile()) { return { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight }; } return { x: MARGIN, y: MARGIN, width: window.innerWidth - MARGIN * 2, height: window.innerHeight - MARGIN * 2 }; } function setButtonTip(btn, tip) { if (!btn) return; btn.setAttribute("data-tip", tip); btn.title = tip; btn.setAttribute("aria-label", tip); } function syncMaximizeButton() { if (!maximizeBtn) return; if (isMaximized) { maximizeBtn.textContent = "⤢"; setButtonTip(maximizeBtn, "Restore size · 還原大小"); } else { maximizeBtn.textContent = "⛶"; setButtonTip(maximizeBtn, "Maximize · 放大"); } } function setMaximized(max) { if (isMinimized || isMobile() && isMinimized) return; if (max === isMaximized) return; isMaximized = max; panel.classList.toggle("is-maximized", max); syncMaximizeButton(); if (max) { preMaximizeLayout = layout; var next = maximizeLayout(); layout = next; panel.style.left = next.x + "px"; panel.style.top = next.y + "px"; panel.style.width = next.width + "px"; panel.style.height = next.height + "px"; expandedLayout = next; return; } if (preMaximizeLayout) { applyLayout(preMaximizeLayout); expandedLayout = layout; preMaximizeLayout = null; } else if (!isMobile()) { applyLayout(expandedLayout || defaultLayout()); } else { applyLayout(mobileDockLayout()); } } function defaultLayout() { if (isMobile()) { return mobileDockLayout(); } var w = DEFAULT_W; var h = DEFAULT_H; return { x: Math.max(MARGIN, window.innerWidth - w - MARGIN), y: Math.max(MARGIN, window.innerHeight - h - MARGIN), width: w, height: h }; } function syncMobileMode() { panel.classList.toggle("sens-ai-panel--mobile-dock", isMobile()); } function clampLayout(next, opts) { opts = opts || {}; if (isMaximized && !isMinimized) { return next; } if (isMobile() && !isMinimized && !opts.allowFreePosition) { return mobileDockLayout(); } var minW = opts.minWidth || MIN_W; var minH = opts.minHeight || MIN_H; var maxW = window.innerWidth - MARGIN * 2; var maxH = window.innerHeight - MARGIN * 2; var width = Math.max(minW, Math.min(next.width, maxW)); var height = Math.max(minH, Math.min(next.height, maxH)); var x = Math.max(MARGIN, Math.min(next.x, window.innerWidth - width - MARGIN)); var y = Math.max(MARGIN, Math.min(next.y, window.innerHeight - height - MARGIN)); return { x: x, y: y, width: width, height: height }; } function loadMinimizedPreference() { try { if (sessionStorage.getItem(STORAGE_NAV_OPEN) === "1") { sessionStorage.removeItem(STORAGE_NAV_OPEN); return false; } var raw = localStorage.getItem(STORAGE_MIN); if (raw === "0") return false; if (raw === "1") return true; } catch (e) {} return true; } function saveChatSession() { try { sessionStorage.setItem( STORAGE_SESSION, JSON.stringify({ html: messagesEl.innerHTML, history: history }) ); } catch (e) {} } function restoreChatSession() { try { var raw = sessionStorage.getItem(STORAGE_SESSION); if (!raw) return false; var data = JSON.parse(raw); if (data && data.html) { messagesEl.innerHTML = data.html; ensureBotMessageAvatars(); } if (data && Array.isArray(data.history)) { history = data.history; } return !!(data && data.html); } catch (e) {} return false; } function clearChatSession() { try { sessionStorage.removeItem(STORAGE_SESSION); } catch (e) {} } function persistOpenForNavigation() { try { localStorage.setItem(STORAGE_MIN, "0"); sessionStorage.setItem(STORAGE_NAV_OPEN, "1"); } catch (e) {} saveChatSession(); } function isWineProductLink(link) { if (!link || !link.href) return false; if (link.getAttribute("href") === "#") return false; return ( link.classList.contains("sens-ai-btn--link") || (link.closest(".sens-ai-wine-title") && link.closest("#sens-ai-panel")) ); } function handleWineProductNavigation(event) { var link = event.target.closest("#sens-ai-panel a.sens-ai-btn--link, #sens-ai-panel .sens-ai-wine-title a"); if (!link || !isWineProductLink(link)) return; persistOpenForNavigation(); } function loadLayout() { if (isMobile()) { return mobileDockLayout(); } try { var raw = localStorage.getItem(STORAGE_LAYOUT); if (raw) return clampLayout(JSON.parse(raw), { allowFreePosition: true }); } catch (e) {} return defaultLayout(); } function saveLayout(next) { try { localStorage.setItem(STORAGE_LAYOUT, JSON.stringify(next)); } catch (e) {} } function applyLayout(next) { layout = clampLayout(next, isMinimized ? { minWidth: 180, minHeight: MINIMIZED_H, allowFreePosition: true } : {}); panel.style.left = layout.x + "px"; panel.style.top = layout.y + "px"; panel.style.width = layout.width + "px"; panel.style.height = layout.height + "px"; if (!isMobile() && !isMaximized && !isMinimized) saveLayout(layout); } function setMinimized(min) { if (min && isMaximized) setMaximized(false); isMinimized = min; panel.classList.remove("is-minimized"); panel.setAttribute("aria-hidden", min ? "true" : "false"); launcher.hidden = !min; try { localStorage.setItem(STORAGE_MIN, min ? "1" : "0"); } catch (e) {} if (min) { if (layout) expandedLayout = layout; panel.classList.remove("is-ready"); } else { panel.classList.add("is-ready"); applyLayout(isMobile() ? mobileDockLayout() : (expandedLayout || defaultLayout())); } } function openPanel() { setMinimized(false); inputEl.focus(); } function initPanelPosition() { layout = loadLayout(); expandedLayout = layout; applyLayout(layout); restoreChatSession(); ensureBotMessageAvatars(); setMinimized(loadMinimizedPreference()); scrollMessages(); } function onPointerMove(clientX, clientY) { if (dragState) { if (isMobile()) { endPointer(); return; } applyLayout({ x: dragState.originX + (clientX - dragState.startX), y: dragState.originY + (clientY - dragState.startY), width: layout.width, height: layout.height }); if (!isMinimized) expandedLayout = layout; } if (resizeState && !isMinimized && !isMobile()) { var dx = clientX - resizeState.startX; var dy = clientY - resizeState.startY; var x = resizeState.originX; var y = resizeState.originY; var w = resizeState.originW; var h = resizeState.originH; var handle = resizeState.handle; if (handle.indexOf("e") >= 0) w = resizeState.originW + dx; if (handle.indexOf("w") >= 0) { w = resizeState.originW - dx; x = resizeState.originX + dx; } if (handle.indexOf("s") >= 0) h = resizeState.originH + dy; if (handle.indexOf("n") >= 0) { h = resizeState.originH - dy; y = resizeState.originY + dy; } var next = clampLayout({ x: x, y: y, width: w, height: h }); if (handle.indexOf("w") >= 0) next.x = resizeState.originX + resizeState.originW - next.width; if (handle.indexOf("n") >= 0) next.y = resizeState.originY + resizeState.originH - next.height; applyLayout(next); expandedLayout = layout; } } function endPointer(e) { if (dragState && headerClickStart && !isMinimized) { var clientX = headerClickStart.x; var clientY = headerClickStart.y; if (e) { if (typeof e.clientX === "number") { clientX = e.clientX; clientY = e.clientY; } else if (e.changedTouches && e.changedTouches[0]) { clientX = e.changedTouches[0].clientX; clientY = e.changedTouches[0].clientY; } } var dx = clientX - headerClickStart.x; var dy = clientY - headerClickStart.y; if (dx * dx + dy * dy <= HEADER_CLICK_THRESHOLD * HEADER_CLICK_THRESHOLD) { setMinimized(true); } } headerClickStart = null; if (dragState || resizeState) { dragState = null; resizeState = null; isDragging = false; isResizing = false; panel.classList.remove("is-dragging", "is-resizing"); document.body.style.userSelect = ""; } } function beginDrag(clientX, clientY) { if (isMobile()) return; if (isMaximized) setMaximized(false); isDragging = true; panel.classList.add("is-dragging"); dragState = { startX: clientX, startY: clientY, originX: layout.x, originY: layout.y }; document.body.style.userSelect = "none"; } function beginResize(handle, clientX, clientY) { if (isMinimized) return; if (isMaximized) setMaximized(false); isResizing = true; panel.classList.add("is-resizing"); resizeState = { handle: handle, startX: clientX, startY: clientY, originX: layout.x, originY: layout.y, originW: layout.width, originH: layout.height }; document.body.style.userSelect = "none"; } dragHandle.addEventListener("mousedown", function (e) { if (e.button !== 0 || isMinimized) return; if (e.target.closest(".sens-ai-panel__icon-btn")) return; headerClickStart = { x: e.clientX, y: e.clientY }; e.preventDefault(); beginDrag(e.clientX, e.clientY); }); dragHandle.addEventListener("touchstart", function (e) { if (isMinimized) return; if (e.target.closest(".sens-ai-panel__icon-btn")) return; var t = e.touches[0]; if (!t) return; headerClickStart = { x: t.clientX, y: t.clientY }; beginDrag(t.clientX, t.clientY); }, { passive: true }); panel.querySelectorAll("[data-resize]").forEach(function (el) { function start(e, clientX, clientY) { e.preventDefault(); e.stopPropagation(); beginResize(el.getAttribute("data-resize"), clientX, clientY); } el.addEventListener("mousedown", function (e) { if (e.button !== 0) return; start(e, e.clientX, e.clientY); }); el.addEventListener("touchstart", function (e) { var t = e.touches[0]; if (!t) return; start(e, t.clientX, t.clientY); }, { passive: false }); }); window.addEventListener("mousemove", function (e) { onPointerMove(e.clientX, e.clientY); }); window.addEventListener("mouseup", endPointer); window.addEventListener("touchmove", function (e) { var t = e.touches[0]; if (t && (dragState || resizeState)) onPointerMove(t.clientX, t.clientY); }, { passive: true }); window.addEventListener("touchend", endPointer); window.addEventListener("resize", function () { syncMobileMode(); if (isMaximized && !isMinimized) { var next = maximizeLayout(); layout = next; panel.style.left = next.x + "px"; panel.style.top = next.y + "px"; panel.style.width = next.width + "px"; panel.style.height = next.height + "px"; expandedLayout = next; return; } if (isMobile() && !isMinimized) { applyLayout(mobileDockLayout()); expandedLayout = layout; } else if (layout && !isMinimized) { applyLayout(layout); } }); minimizeBtn.addEventListener("click", function (e) { e.stopPropagation(); setMinimized(true); }); if (maximizeBtn) { maximizeBtn.addEventListener("click", function (e) { e.stopPropagation(); if (isMinimized) return; setMaximized(!isMaximized); }); } if (newChatBtn) { newChatBtn.addEventListener("click", function (e) { e.stopPropagation(); startNewChat(); }); } launcher.addEventListener("click", openPanel); function resetInputHeight() { inputEl.style.height = "24px"; } function growInputHeight() { inputEl.style.height = "24px"; inputEl.style.height = Math.min(inputEl.scrollHeight, 180) + "px"; } function escapeHtml(text) { return String(text) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function formatReply(text) { var safe = escapeHtml(text || ""); safe = safe.replace(/\*\*(.+?)\*\*/g, "$1"); return safe.replace(/\n/g, "
"); } var LOADING_MESSAGE = "Mirai is thinking…"; var READ_MORE_LABEL = "Read more"; var READ_MORE_LABEL_LESS = "Show less"; var READ_MORE_SKIP_REPLY_RE = [ /^Finding wines for you/i, /^Thinking/i, /^Just a moment/i, /^One moment/i, /^Sorry, something went wrong/i, /couldn't find/i, /could not find/i, /no specific wine/i, /checked our current selection/i, /^暫時未/i, /^今次未/i, /^現在、ご希望/i, /^今回のご希望/i, ]; function buildReadMoreHtml(previewText, extraClass, fullText, previewHtml) { var preview = String(previewText || "").trim(); var full = String(fullText || preview).trim(); var cls = "sens-ai-read-more" + (extraClass ? " " + extraClass : ""); var inner = previewHtml != null ? previewHtml : escapeHtml(preview); return ( '
' + '
' + inner + "
" + '
" ); } function textLooksTruncated(text) { return /(?:…|\.\.\.)[\s]*$/.test(String(text || "").trim()); } function isReadMoreExcludedReply(text) { var t = String(text || "").trim(); for (var i = 0; i < READ_MORE_SKIP_REPLY_RE.length; i++) { if (READ_MORE_SKIP_REPLY_RE[i].test(t)) return true; } return false; } function shouldUseReadMoreReply(previewText, fullText) { if (isReadMoreExcludedReply(previewText)) return false; var preview = String(previewText || "").trim(); var full = String(fullText || preview).trim(); if (!preview) return false; if (full.length > preview.length + 8) return true; if (textLooksTruncated(preview)) return true; if (preview.length >= 360) return true; return false; } function shouldUseReadMoreCard(previewText, fullText) { var preview = String(previewText || "").trim(); var full = String(fullText || preview).trim(); if (!preview) return false; if (full.length > preview.length + 8) return true; if (textLooksTruncated(preview)) return true; if (preview.length >= 120) return true; return false; } function extractBoldWineNames(text) { var names = []; var re = /\*\*([^*]{3,140})\*\*/g; var match; while ((match = re.exec(String(text || ""))) !== null) { var name = match[1].replace(/\s+/g, " ").trim(); if (name && !/^(red|white|wine|wines|紅酒|白酒)$/i.test(name)) names.push(name); } return names; } function normalizeTitleKey(title) { return String(title || "") .toLowerCase() .replace(/[^a-z0-9\u4e00-\u9fff]+/gi, " ") .replace(/\s+/g, " ") .trim(); } function titleMatchesCandidate(wineTitle, candidate) { var a = normalizeTitleKey(wineTitle); var b = normalizeTitleKey(candidate); if (!a || !b) return false; if (a === b || a.indexOf(b) >= 0 || b.indexOf(a) >= 0) return true; var words = a.split(" ").filter(function (w) { return w.length > 2 || /^\d{4}$/.test(w); }); if (!words.length) return false; var hits = 0; for (var i = 0; i < words.length; i++) { if (b.indexOf(words[i]) >= 0) hits += 1; } return hits >= Math.min(3, Math.max(2, Math.ceil(words.length * 0.45))); } function findWineForName(name, wines) { if (!wines || !wines.length) return null; for (var i = 0; i < wines.length; i++) { if (titleMatchesCandidate(wines[i].title, name)) return wines[i]; } return null; } function isWineToFoodIntroBlock(block) { return /你問|You asked|what to pair with|可以配什么|に合う料理/i.test(block); } function isPairingTheoryBlock(block) { return ( /With that in mind, these SENS bottles stand out/i.test(block) || /在這個基礎上,SENS 酒窖/i.test(block) || /I start with wine traits/i.test(block) || /我會先從菜式需要嘅酒質入手/i.test(block) || /sweetness is the key — the wine should be as sweet or sweeter/i.test(block) || /甜度係關鍵 — 酒應同甜品一樣甜或更甜/i.test(block) ); } function isLikelyWinePickBlock(block) { if (!/\*\*[^*]+\*\*/.test(block)) return false; if (isWineToFoodIntroBlock(block)) return false; if (isPairingTheoryBlock(block)) return false; if (/HK\$|HKD|(\s*HK| at HK/i.test(block)) return true; if ( /^(?:First|Next|Another option|For red|For white|首先|另外|第三支|第四支|第五支|第六支|第七支|第八支|紅酒方面|白酒我會揀)/im.test( block ) ) { return true; } return false; } function isGenericPairingAdviceComment(text) { var cleaned = String(text || "").trim(); if (!cleaned) return false; return ( /With that in mind, these SENS bottles stand out/i.test(cleaned) || /在這個基礎上,SENS 酒窖/i.test(cleaned) || /I start with wine traits/i.test(cleaned) || /我會先從菜式需要嘅酒質入手/i.test(cleaned) || /In practice:\s*\*\*/i.test(cleaned) || /按呢個思路:/i.test(cleaned) || (/\*\*(?:Pinot Grigio|Vermentino|Albariño|Chablis|Chianti|Barolo)\*\*/i.test(cleaned) && !/HK\$|HKD| at HK/i.test(cleaned)) ); } function isProvenanceOnlyComment(text) { var cleaned = String(text || "").trim(); if (!cleaned) return false; return ( /^from\s+.+(?:\s*[·•]\s*)?(?:\d{4}\s*)?vintage\s*[—–-]\s*drinking well now\.?$/i.test(cleaned) || /^from\s+.+\s*[—–-]\s*drinking well now\.?$/i.test(cleaned) || /^產區係\s+.+(?:,已進入適飲期)?。?$/u.test(cleaned) ); } function isBudgetOnlyComment(text) { var cleaned = String(text || "").trim(); if (!cleaned) return false; if (!/within your budget|fits your search|matches your search|在你預算|風格貼合今次搜尋|符合你今次搜尋/i.test(cleaned)) { return false; } var stripped = cleaned .replace(/\bat\s+HK\$[\d,]+/gi, "") .replace(/HK\$[\d,]+/gi, "") .replace(/HKD\s*[\d,]+/gi, "") .replace(/within your budget and fits your search\.?/gi, "") .replace(/within your budget\.?/gi, "") .replace(/fits your search\.?/gi, "") .replace(/matches your search\.?/gi, "") .replace(/在你預算之內[,,]?/g, "") .replace(/風格貼合今次搜尋\.?/g, "") .replace(/符合你今次搜尋[^。]*\.?/g, "") .replace(/^[,,。.!!?\s—–-]+|[,,。.!!?\s—–-]+$/g, "") .trim(); return stripped.length < 16; } function stripProvenanceTail(text) { return String(text || "") .replace(/\s+from\s+[^.]+?\s*[—–-]\s*drinking well now\.?\s*$/i, "") .replace(/\s+產區係\s+[^。]+?(?:,已進入適飲期)?。?\s*$/u, "") .trim(); } function cleanWineReplyComment(block, wineTitle) { var text = String(block || "").trim(); text = text.replace(/\*\*([^*]+)\*\*/g, function (_match, inner) { return titleMatchesCandidate(wineTitle, inner) ? "" : inner; }); text = text.replace(/[((]\s*HK\$[\d,]+[^))]*[))]/gi, " "); text = text.replace(/[((]\s*HKD\s*[\d,]+[^))]*[))]/gi, " "); text = text.replace(/\bat\s+HK\$[\d,]+/gi, " "); text = text.replace( /^(?:First|Next|Another option|For red[^,—–-]*[,—–-]|For white[^,—–-]*[,—–-]|首先[,,]?|另外[,,]?|第三支[,,]?|第四支[,,]?|紅酒方面[,,][^,,—–-]*[,,—–-]?|白酒我會揀)\s*/i, "" ); text = text.replace(/^[—–\-:,,。\s]+/, "").replace(/\s+/g, " ").trim(); text = stripProvenanceTail(text); return text; } function prepareReplyAndWines(reply, wines) { if (!wines || !wines.length) { return { bubbleText: reply, cardWines: [], wineComments: {} }; } var blocks = String(reply || "") .split(/\n{2,}/) .map(function (block) { return block.trim(); }) .filter(Boolean); var featured = []; var wineComments = {}; var pickBlockIndexes = {}; var seenTitles = {}; blocks.forEach(function (block, index) { if (!isLikelyWinePickBlock(block)) return; var names = extractBoldWineNames(block); var matchedWine = null; for (var i = 0; i < names.length; i++) { var candidate = findWineForName(names[i], wines); if (candidate && !seenTitles[candidate.title]) { matchedWine = candidate; break; } } if (!matchedWine) return; var comment = cleanWineReplyComment(block, matchedWine.title); if (comment.length < 8 || isBudgetOnlyComment(comment)) return; wineComments[matchedWine.title] = comment; pickBlockIndexes[index] = true; featured.push(matchedWine); seenTitles[matchedWine.title] = true; }); if (!featured.length) { wines.forEach(function (wine) { var reason = sanitizeCardReason(wine.match_reason || wine.pairing_reason || ""); if (!reason) return; wineComments[wine.title] = reason; featured.push(wine); }); if (!featured.length) { return { bubbleText: reply, cardWines: wines, wineComments: {} }; } } var bubbleParts = []; blocks.forEach(function (block, index) { if (pickBlockIndexes[index]) return; bubbleParts.push(block); }); return { bubbleText: bubbleParts.join("\n\n") || reply, cardWines: wines, wineComments: wineComments, }; } function storefrontRoot() { return String(CONFIG.shopUrl || window.location.origin || "").replace(/\/+$/, ""); } function wineProductUrl(wine) { var root = storefrontRoot(); var url = String((wine && wine.url) || "").trim(); var handle = String((wine && wine.handle) || "").trim(); if (url && url !== "#") { url = url.replace(/\/apps\/sens-ai\/products\//i, "/products/"); if (/^https?:\/\//i.test(url)) { var absoluteMatch = url.match(/^(https?:\/\/[^/]+)(\/products\/[^/?#]+)/i); if (absoluteMatch) return absoluteMatch[1] + absoluteMatch[2]; return url; } if (url.charAt(0) === "/") return root + url; var relativeMatch = url.match(/\/products\/([^/?#]+)/i); if (relativeMatch) return root + "/products/" + relativeMatch[1]; return url; } if (handle) return root + "/products/" + handle.replace(/^\/+|\/+$/g, ""); return "#"; } function wineVariantId(wine) { var raw = wine && wine.variant_id; if (raw == null || raw === "") return null; var parsed = Number(raw); if (!Number.isFinite(parsed) || parsed <= 0) return null; return parsed; } function formatReplyWithReadMore(text) { var trimmed = String(text || "").trim(); if (!trimmed) return ""; var formatted = formatReply(trimmed); if (!shouldUseReadMoreReply(trimmed, trimmed)) return formatted; return buildReadMoreHtml(trimmed, "sens-ai-read-more--reply", trimmed, formatted); } function defaultWineComment(wine) { var notesPreview = String(wine.tasting_notes || "").trim(); if (notesPreview) return notesPreview; var reason = sanitizeCardReason(wine.match_reason || wine.pairing_reason || ""); if (reason) return reason; var notesFull = String(wine.tasting_notes_full || "").trim(); if (notesFull) return notesFull; var title = String(wine.title || "this bottle").trim(); return "A curated pick from the SENS cellar — " + title + "."; } function cardCommentPreviewFull(wine, replyComment) { var desc = wineCardDescription(wine, replyComment); var preview = desc.preview || defaultWineComment(wine); var full = desc.full || preview; var notesPreview = String(wine.tasting_notes || "").trim(); var notesFull = String(wine.tasting_notes_full || "").trim(); if (notesFull && notesFull.length > preview.length + 8) { var previewStem = preview.replace(/(?:…|\.\.\.)[\s]*$/, "").trim().toLowerCase(); var notesStem = notesPreview.replace(/(?:…|\.\.\.)[\s]*$/, "").trim().toLowerCase(); var matchesNotes = (notesPreview && (preview === notesPreview || (notesStem && previewStem === notesStem))) || (previewStem.length >= 16 && notesFull.toLowerCase().indexOf(previewStem.slice(0, Math.min(56, previewStem.length))) === 0); if (matchesNotes || (textLooksTruncated(preview) && !desc.fromReply)) { preview = notesPreview || preview; full = notesFull; } } return { preview: preview, full: full }; } function wineCardDescription(wine, replyComment) { var comment = String(replyComment || "").trim(); if (comment) { comment = stripProvenanceTail(comment); } if (comment && isBudgetOnlyComment(comment)) comment = ""; var reason = sanitizeCardReason(wine.match_reason || wine.pairing_reason || ""); var notesPreview = String(wine.tasting_notes || "").trim(); var notesFull = String(wine.tasting_notes_full || "").trim(); if ( comment && !isProvenanceOnlyComment(comment) && !isGenericPairingAdviceComment(comment) && (!reason || comment.length >= Math.min(reason.length, 40)) ) { return { preview: comment, full: comment, fromReply: true }; } if (reason) { if (notesFull && notesFull.length > reason.length + 8 && textLooksTruncated(reason)) { return { preview: notesPreview || reason, full: notesFull, fromReply: false }; } return { preview: reason, full: reason, fromReply: false }; } if (notesPreview) { return { preview: notesPreview, full: notesFull || notesPreview, fromReply: false }; } if (comment) { return { preview: comment, full: comment, fromReply: true }; } var fallback = defaultWineComment(wine); return { preview: fallback, full: fallback, fromReply: false }; } function toggleReadMore(btn) { var wrap = btn.closest("[data-read-more]"); if (!wrap) return; var content = wrap.querySelector(".sens-ai-read-more__content"); var expanded = wrap.classList.toggle("is-expanded"); var preview = wrap.getAttribute("data-read-more-preview") || ""; var full = wrap.getAttribute("data-read-more-full") || preview; if (content && full !== preview) { content.textContent = expanded ? full : preview; } btn.setAttribute("aria-expanded", expanded ? "true" : "false"); btn.textContent = expanded ? READ_MORE_LABEL_LESS : READ_MORE_LABEL; } function formatPrice(value) { if (value == null || value === "") return ""; var n = Number(value); if (isNaN(n)) return ""; return "HK$" + n.toLocaleString("en-HK", { maximumFractionDigits: 0 }); } function scrollMessages() { messagesEl.scrollTop = messagesEl.scrollHeight; } function criticScoresFromTitle(title) { var re = /\b(RP|JS|WS|BH|WE|WH|NM|VN|Vinous)\s*:?\s*(\d{2})/gi; var parts = []; var seen = {}; var match; while ((match = re.exec(title || "")) !== null) { var src = match[1].toUpperCase(); if (src === "VINOUS") src = "VN"; var label = src + ":" + match[2]; if (seen[label]) continue; seen[label] = true; parts.push(label); } return parts.join(" · "); } function wineProfileText(wine, cardComment) { return [ wine.tasting_notes, cardComment, wine.match_reason, wine.pairing_reason, wine.title, ] .filter(Boolean) .join(" "); } var MIN_NOTES_FOR_RADAR = 50; var MIN_NOTES_STRONG = 80; var MIN_PROFILE_SPREAD = 0.35; function profileSpread(profile) { if (!profile || !profile.length) return 0; var vals = profile.map(function (d) { return Number(d.value); }); return Math.max.apply(null, vals) - Math.min.apply(null, vals); } function profileFromServer(wine) { if (wine.show_tasting_profile === false) return null; var raw = wine.tasting_profile; if (!raw || !Array.isArray(raw) || raw.length < 5) return null; return raw.map(function (d) { return { key: d.key, label: d.label || d.key, value: clampProfileScore(d.value), }; }); } function shouldShowTastingRadar(wine, profile) { return !!(profile && profile.length >= 5); } function resolveWineProfile(wine, cardComment) { var server = profileFromServer(wine); if (server) return server; return buildWineProfileDimensions(wine, cardComment); } function clampProfileScore(value) { var n = Number(value); if (isNaN(n)) n = 3.2; return Math.max(1.8, Math.min(4.9, Math.round(n * 10) / 10)); } function keywordScore(text, patterns, base, boost) { var hits = 0; for (var i = 0; i < patterns.length; i++) { if (patterns[i].test(text)) hits += 1; } return clampProfileScore(base + Math.min(hits * (boost || 0.22), 1.35)); } function extractVintageYear(text) { var match = String(text || "").match(/\b(19|20)\d{2}\b/); return match ? parseInt(match[0], 10) : null; } function criticScoreAverage(text) { var re = /\b(RP|JS|WS|BH|WE|WH|NM|VN|Vinous)\s*:?\s*(\d{2})/gi; var total = 0; var count = 0; var match; while ((match = re.exec(text || "")) !== null) { total += parseInt(match[2], 10); count += 1; } if (!count) return null; return total / count; } function buildWineProfileDimensions(wine, cardComment) { var text = wineProfileText(wine, cardComment).toLowerCase(); var vintage = wine.vintage || extractVintageYear(wine.title || ""); var vintageNum = vintage ? parseInt(String(vintage), 10) : null; var nowYear = new Date().getFullYear(); var vintageScore = 3.1; if (vintageNum && vintageNum >= 1950 && vintageNum <= nowYear) { var age = nowYear - vintageNum; if (age <= 3) vintageScore = 3.4; else if (age <= 8) vintageScore = 3.8; else if (age <= 18) vintageScore = 4.2; else if (age <= 30) vintageScore = 4.0; else vintageScore = 3.6; } var criticAvg = criticScoreAverage(text); var rankingScore = criticAvg ? clampProfileScore(1.8 + (criticAvg - 80) * 0.08) : 3.0; return [ { key: "body", label: "Body", value: keywordScore( text, [ /full[\s-]?bod/i, /rich/i, /bold/i, /concentrated/i, /dense/i, /heavy/i, /酒體|醇厚|豐滿|濃郁/, ], /light|delicate|elegant|thin|crisp|轻盈|輕盈|清爽/.test(text) ? 2.8 : 3.35, 0.2 ), }, { key: "acidity", label: "Acidity", value: keywordScore( text, [ /acid/i, /crisp/i, /fresh/i, /bright/i, /zesty/i, /vibrant/i, /酸度|清脆|爽脆|明亮/, ], 3.0, 0.24 ), }, { key: "aroma", label: "Aroma", value: keywordScore( text, [ /aroma/i, /nose/i, /bouquet/i, /berry|cherry|plum|citrus|floral|spice|oak|vanilla|mineral/i, /香氣|芳香|果香|花香|礦物/, ], 3.15, 0.21 ), }, { key: "aging", label: "Cellaring", value: keywordScore( text, [ /age/i, /cellar/i, /cellaring/i, /tannin|structure|grip|backbone/i, /long[\s-]?term/i, /陳年|適飲|潛力|結構|單寧/, ], vintageScore - 0.15, 0.2 ), }, { key: "finish", label: "Finish", value: keywordScore( text, [ /finish/i, /aftertaste/i, /persistent/i, /lingering/i, /length/i, /long/i, /餘韻|尾韻|回甘/, ], 3.05, 0.22 ), }, ].map(function (dim) { if (dim.key === "aging" && criticAvg) { dim.value = clampProfileScore((dim.value + rankingScore) / 2); } return dim; }); } var PROFILE_BAR_BASELINE = 3.0; var PROFILE_BAR_CONTRAST = 1.5; function profileBarWidth(value) { var score = clampProfileScore(value); var amplified = clampProfileScore( PROFILE_BAR_BASELINE + (score - PROFILE_BAR_BASELINE) * PROFILE_BAR_CONTRAST ); return Math.max(6, Math.min(100, Math.round(((amplified - 1.8) / 3.1) * 100))); } function buildWineProfileBarsHtml(profile) { var parts = ['"); return parts.join(""); } function buildWineProfileHtml(wine, cardComment) { var profile = resolveWineProfile(wine, cardComment); var showRadar = shouldShowTastingRadar(wine, profile); var critics = String(wine.critic_scores || "").trim(); if (!critics && wine.title) critics = criticScoresFromTitle(wine.title); var html = '
'; if (showRadar) { html += '

Tasting profile · 品飲印象

'; html += '
' + buildWineProfileBarsHtml(profile) + "
"; } html += '
'; html += 'Wine Critics'; if (critics) { html += '' + escapeHtml(critics) + ""; } else { html += 'N/A'; } html += "
"; return html; } function sanitizeCardReason(text) { var cleaned = String(text || "").trim(); if (!cleaned) return ""; if (isBudgetOnlyComment(cleaned)) return ""; var genericPrefixes = [ /^符合你今次搜尋的風格與條件[。.\s]*/u, /^紅酒風格符合你今次搜尋[。.\s]*/u, /^白酒風格符合你今次搜尋[。.\s]*/u, /^Matches the style and criteria for your search[.:\s]*/i, /^Red wine style matches what you asked for[.:\s]*/i, /^White wine style matches what you asked for[.:\s]*/i, /^At HK\$[\d,]+,?\s*within your budget and fits your search[.:\s]*/i, /^within your budget and fits your search[.:\s]*/i, /^HK\$[\d,]+[,,]?\s*在你預算之內[,,]?\s*風格貼合今次搜尋[。.\s]*/u, ]; for (var i = 0; i < genericPrefixes.length; i++) { cleaned = cleaned.replace(genericPrefixes[i], ""); } cleaned = cleaned.trim(); if (isBudgetOnlyComment(cleaned)) return ""; return cleaned; } function buildWineMetaRowHtml(wine, price) { if (!price) return ""; return '

' + price + "

"; } function buildWineGridHtml(wines, wineComments) { if (!wines || !wines.length) return ""; wineComments = wineComments || {}; var html = '

From our cellar · 酒窖精選

'; wines.forEach(function (wine) { var title = escapeHtml(wine.title || "Wine"); var url = wineProductUrl(wine); var price = formatPrice(wine.price != null ? wine.price : wine.price_hkd); var image = wine.image || ""; var variantId = wineVariantId(wine); var commentText = cardCommentPreviewFull(wine, wineComments[wine.title]); var preview = commentText.preview; var full = commentText.full; html += '
'; if (image) { html += '' + title + ''; } else { html += ''; } html += '

' + title + "

"; html += buildWineMetaRowHtml(wine, price); html += '

Comments:

'; if (shouldUseReadMoreCard(preview, full)) { html += buildReadMoreHtml(preview, "sens-ai-read-more--card", full); } else { html += '

' + escapeHtml(preview) + "

"; } html += buildWineProfileHtml(wine, full || preview); html += '
'; if (variantId) { html += ''; } else { html += ''; } html += 'View
'; }); html += "
"; return html; } function removeStaleWineCards() { messagesEl.querySelectorAll(".sens-ai-msg__wines").forEach(function (el) { var msg = el.closest(".sens-ai-msg"); if (msg) msg.classList.remove("sens-ai-msg--with-wines"); el.remove(); }); } function appendBotReply(text, wines) { removeStaleWineCards(); var prepared = prepareReplyAndWines(text, wines); var cardWines = prepared.cardWines; var wrap = document.createElement("div"); wrap.className = "sens-ai-msg sens-ai-msg--bot"; if (cardWines && cardWines.length) wrap.className += " sens-ai-msg--with-wines"; wrap.appendChild(createButlerSender()); var stack = document.createElement("div"); stack.className = "sens-ai-msg__stack"; var bubble = document.createElement("div"); bubble.className = "sens-ai-msg__bubble"; bubble.innerHTML = formatReplyWithReadMore(prepared.bubbleText); stack.appendChild(bubble); if (cardWines && cardWines.length) { stack.insertAdjacentHTML("beforeend", buildWineGridHtml(cardWines, prepared.wineComments)); } wrap.appendChild(stack); messagesEl.appendChild(wrap); scrollMessages(); saveChatSession(); return wrap; } function appendMessage(role, text, extraClass) { var wrap = document.createElement("div"); wrap.className = "sens-ai-msg sens-ai-msg--" + (role === "user" ? "user" : "bot"); if (extraClass) wrap.className += " " + extraClass; if (role !== "user") { wrap.appendChild(createButlerSender()); } var bubble = document.createElement("div"); bubble.className = "sens-ai-msg__bubble"; if (role === "user") bubble.textContent = text; else if (extraClass === "sens-ai-msg--loading") bubble.innerHTML = formatReply(text); else bubble.innerHTML = formatReplyWithReadMore(text); wrap.appendChild(bubble); messagesEl.appendChild(wrap); scrollMessages(); if (!extraClass || extraClass !== "sens-ai-msg--loading") { saveChatSession(); } return wrap; } function showToast(message) { toastEl.textContent = message; toastEl.hidden = false; toastEl.classList.add("is-visible"); window.clearTimeout(showToast._timer); showToast._timer = window.setTimeout(function () { toastEl.classList.remove("is-visible"); window.setTimeout(function () { toastEl.hidden = true; }, 260); }, 2600); } function startNewChat() { if (busy) { showToast("Please wait for the current reply…"); return; } history = []; clearChatSession(); messagesEl.innerHTML = ""; var welcome = document.createElement("div"); welcome.className = "sens-ai-msg sens-ai-msg--bot"; welcome.appendChild(createButlerSender()); var stack = document.createElement("div"); stack.className = "sens-ai-msg__stack"; var bubble = document.createElement("div"); bubble.className = "sens-ai-msg__bubble"; bubble.textContent = WELCOME_MESSAGE; stack.appendChild(bubble); welcome.appendChild(stack); messagesEl.appendChild(welcome); inputEl.value = ""; resetInputHeight(); if (!isMinimized) inputEl.focus(); showToast("New chat started"); } messagesEl.addEventListener("click", function (event) { var readMoreBtn = event.target.closest(".sens-ai-read-more__btn"); if (readMoreBtn) { event.preventDefault(); toggleReadMore(readMoreBtn); return; } var btn = event.target.closest("[data-variant-id]"); if (!btn || btn.disabled) return; var variantId = btn.getAttribute("data-variant-id"); btn.disabled = true; btn.textContent = "Adding…"; fetch("/cart/add.js", { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, credentials: "same-origin", body: JSON.stringify({ items: [{ id: Number(variantId), quantity: 1 }] }) }) .then(function (res) { if (!res.ok) throw new Error("cart"); return res.json(); }) .then(function () { btn.textContent = "Added ✓"; showToast("Added to cart"); document.dispatchEvent(new CustomEvent("cart:refresh")); }) .catch(function () { btn.disabled = false; btn.textContent = "Add"; showToast("Could not add to cart."); }); }); formEl.addEventListener("submit", function (event) { event.preventDefault(); if (busy) return; var message = (inputEl.value || "").trim(); if (message.length < 2) return; if (isMinimized) openPanel(); appendMessage("user", message); inputEl.value = ""; resetInputHeight(); busy = true; sendEl.disabled = true; var loadingNode = appendMessage("bot", LOADING_MESSAGE, "sens-ai-msg--loading"); fetch(CONFIG.apiUrl, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, credentials: "same-origin", body: JSON.stringify({ message: message, history: history.slice(-8) }) }) .then(function (res) { return res.json().then(function (data) { if (!res.ok) { var detail = data && data.detail ? data.detail : "Request failed"; throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail)); } return data; }); }) .then(function (data) { loadingNode.remove(); var reply = data.reply || "No reply received."; appendBotReply(reply, data.wines || []); history.push({ role: "user", text: message }); history.push({ role: "assistant", text: reply }); saveChatSession(); }) .catch(function (err) { loadingNode.remove(); appendMessage("bot", "Sorry, something went wrong. Please try again."); showToast(err && err.message ? String(err.message).slice(0, 100) : "Network error"); }) .finally(function () { busy = false; sendEl.disabled = false; inputEl.focus(); }); }); inputEl.addEventListener("keydown", function (event) { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); formEl.requestSubmit(); } }); inputEl.addEventListener("input", growInputHeight); window.addEventListener("pagehide", saveChatSession); document.addEventListener("click", handleWineProductNavigation, true); syncMobileMode(); syncMaximizeButton(); initPanelPosition(); })();