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

Trimbach 'Clos Sainte Hune' Riesling 2018 (RP: 99+, Wine Enthusiast: 95)

FRW0260

Notify me when this product is available:

“If there is one Riesling in the world that every wine lover dreams of tasting and savoring, it is the Clos Sainte Hune...” — Serge Dubs, Best Sommelier of the World 1989

Maison Trimbach 是阿爾薩斯最古老的酒莊之一,歷史可追溯到1626 年。十三代家族釀酒師,從葡萄種植到釀造、裝瓶都秉持嚴謹精神,建立了以 dry Riesling 為核心的「Trimbach Style——結構嚴謹、優雅平衡、果香純淨,並具備豐富的陳年潛力。

Clos Sainte Hune 位於 Hunawihr 的 Grand Cru Rosacker,屬於 Trimbach 家族超過兩百年。僅 1.67 公頃的葡萄園,擁有逾五十年樹齡的老藤,坐落於向南至東南的坡地,底土由 Muschelkalk(貝殼石灰岩)與黃色石灰岩構成,賦予 Riesling 特有的香氣、濃縮果味與精緻礦物感。

2018 年份產量極少,僅約 7000 瓶,陳年潛力超越 10 年,深受全球 Riesling 愛好者與收藏家追捧。

產區保護條例:
阿爾薩斯 Clos Sainte Hune (位於特級園 Rosacker)

產地:
法國

種類:
白葡萄酒

葡萄品種:
Riesling

酒精度:
14.5%

容量:
750毫升

品酒筆記:
(Robert Parker's rating: 99+ points)
2018 年份的 Clos Ste Hune Riesling,香氣馥郁又精緻優雅,散發出濃郁的檸檬香氣,預示出極大的豐厚感。整體非常和諧,酒體飽滿,濃郁而集中,同時充滿活力與張力。這是一款非常豐富有力量、卻依然優雅的 Clos Ste Hune,兼具細膩的口感、豐富的礦物鹹感、力量和內涵。這是一款巨作。

(Wine Enthusiast's rating: 95 points)
如天鵝絨般厚重柔滑,卻依然細膩優雅。結構均衡,酸度清新,層次豐富,散發著榴蓮、菠蘿、醃薑、粉紅葡萄柚糖果和礦物感,餘韻悠長,回味無窮。精緻而複雜,正逐漸展現全部的魅力。適飲期:現在至 2035 年。


Maison Trimbach, founded in 1626, is one of the oldest and most respected wineries in Alsace. For thirteen generations, the family has maintained a rigorous commitment to quality, overseeing every stage from vineyard to bottle. Their hallmark “Trimbach Style” is defined by dry wines of strict structure, elegance, balance, and remarkable longevity, with Riesling as the undisputed flagship.

At the heart of this tradition lies Clos Sainte Hune, a legendary vineyard within the Grand Cru Rosacker in Hunawihr. Owned by the Trimbach family for more than two centuries, this tiny 1.67‑hectare parcel of over fifty‑year‑old vines enjoys a south to southeast exposure and Muschelkalk limestone soils. These conditions yield Rieslings of extraordinary fragrance, concentrated fruit, and refined minerality, with a length and precision that have become the signature of Clos Sainte Hune.

The 2018 vintage is produced in tiny quantities of around 7,000 bottles, making it one of the world’s most sought‑after Rieslings, renowned for its remarkable aging potential well beyond a decade.

Appellation:
Clos Sainte Hune (Grand Cru Rosacker), Alsace

Country of Origin:
France

Type:
White/Blanc (Still)

Grape Variety:
Riesling

Alcohol Content:
14.5%

Volume:
750mL

Tasting Notes:
(Robert Parker's rating: 99+ points)
The 2018 Riesling Clos Ste Hune is deep and intense on the very generous yet refined and elegant, rich and lemon-scented nose that indicates enormous richness. It's very harmonious. Full-bodied, very intense and concentrated yet also vital and full of tension, this is a very rich and powerful yet elegant und utterly mineral CSH that combines great finesse and salinity with power and substance. This is a giant.

(Wine Enthusiast's rating: 95 points)
Like heavy velvet in texture and weight, but so fine and elegant, this well-focused Riesling possesses a backbone of mouth-watering acidity and offers layers of durian, pineapple, pickled ginger, pink grapefruit candy and stony mineral with a long, savory finish. It is refined and complex, only starting to reveal all of its facets. Drink now-2035. 



Next Previous

(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(); })();