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

Bergerie de l'Hortus Rosé 2022

FRRS0008

Size

Bergerie系列有紅酒、粉紅酒和白酒。紅葡萄種植於斜坡下的地塊,白葡萄種於山谷底部,與山丘上的葡萄相比,較少暴露於陽光中。總括來說,成熟期比Domaine de l’Hortus cuvee所用的葡萄來得較晚。用作紅酒和粉紅酒的葡萄都在較淺層的土壤生長,較接近源石中的石灰岩,對乾旱較敏感。陳年過程需要於仔細的照顧,浸漬和陳年則需較少時間。

產地:
AOC Languedoc

種類:
粉紅葡萄酒

風土:
主要是在山腳的葡萄田。由白堊紀時期已形成,石灰石組成的棕土是主要的土壤。坡度以10-30%不等,高度約150-200米。葡萄較為晚熟。

葡萄品種:
Grenache、Syrah

釀造:
直接壓榨,低溫萃取。發酵3-4個星期。

陳年:
4個月內缸內陳年。冬天結束前裝瓶。

品酒筆記:
一支令人愉快的粉紅酒!精緻的紅果香氣,帶有淡淡的花香。 口感清新細膩,但充滿個性。


This Bergerie range comes in three colours : red, rosé and white. The red grapes grow in land parcels low in the foothills, whilst the whites grow at the bottom of the valley. In both cases the plants are less exposed to the elements than those that grow on the higher slopes. Overall, the grapes mature later than those destined for the Domaine de l’Hortus range.
The red and rosé vines grow in shallower scree with limestone elements closer to the source rock. They are, therefore, more sensitive to drought. The maturing process requires great care in the cellars, with relatively short periods macerating and maturing.

Country of Origin:
AOC Languedoc

Type:
Rosé (Still)

Terroir :
land parcels low in foothills. Brown soil formed from limestone scree dating from the Cretaceous period. Hill slopes varying from 10 to 30% at altitudes from 150-200 metres. Grapes ripen  especially late.

Grape varieties :
Grenache, Syrah

Vinification :
Direct pressure, cold settling, fermentation for 3 to 4 weeks.

Maturing :
4 months in vats. Bottled towards the end of winter.

Tasting Notes:
A rosé of pleasure! Delicate red fruits aroma, with hints of floral notes. The palate is fine and fresh, but full of character.



Next Previous

Customer Reviews

Based on 1 review Write a review
(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(); })();