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

Paisajes y Vinedos Valsalado 2010

ESR00120

1 item left

aisajes y Vinedos (葡萄園的風景系列)

釀造葡萄酒有時不一定要擁有葡萄園、酒廠與酒窖,擁有熱情與天賦,便能產生無與倫比的葡萄酒美景。 

1998年,西班牙最富傳奇色彩的名釀酒師米格爾‧安赫(Miguel Angel de Gregorio)與巴塞隆納最佳葡萄酒專賣店Vila Viniteca決定在利奧哈區(Rioja)攜手進行一個特別的葡萄酒計畫,稱為葡萄園裡的風景(Paisajes y Vinedos),透過米格爾對於產區的了解,每年嚴選利奧哈區不同的單一葡萄園,藉由其優異的釀酒技術,呈現每個葡萄園中無與倫比的景致。

派撒赫 (Paisajes)的西班牙原義為”風景”,希望能展現出園區獨一無二的面貌,米格爾的釀酒哲學是藉由土壤不同的特性與潛質,生產出獨特且無法被模仿的 精采佳釀。由於法訂產區的規範,派撒赫的酒款僅能標示利奧哈(Rioja),而酒款直接以各葡萄園區的編號來命名,目前編號從19號,部份酒款並非年年 釀造,而是選擇最佳年份才生產。派撒赫的酒款風格與釀酒理念已受到各地的注目與好評,著名酒評家羅伯‧派克(Robert Parker)屢屢給予93分的高評價,同時也是連續5年榮獲世界最佳餐廳首選的西班牙三星餐廳El Bulli酒單上的指定用酒,每款酒年產量不超過5000瓶,是全球葡萄酒迷競相選購的夢幻逸品,也是西班牙利奧哈最不能忽略的美景。

 

產區保護條例:
DOC Rioja

產地:
西班牙

種類:
紅葡萄酒

葡萄品種:
40% Tempranillo, 40% Garnacha, 10% Mazuelo,10% Graciano

木桶利用:
100% 新法國Allier木桶

陳年:
12個月

樹齡:
55年

產量:
4385支

品酒筆記:
黑櫻桃色全面的香氣,複雜和誘人,帶辛辣、果、烤麵包皮革和普羅旺斯香草果突出,豐滿酒體,堅固、平整的丹寧,結合完美的橡木味和香料,足夠的酸度提供了新鮮感和悠長餘韻這款酒在未來數年演變更好,但現在喝也同樣優良,可配肉類菜餚

This one, Paisajes V is from a single vineyard (called Valsalado) of mixed grapes which was already owned by the De Gregorio family. 

We think this “single parcel” wine is a knockout! Our first wine from Rioja and exactly the type of small, boutique project wine we are looking for. The nose is just gorgeous and the wine tastes beautifully fresh and rewarding and it was a clear winner in a blind tasting we did with other top wines from the region. With just 4000 bottles available, we have a very limited quantity. Get a few bottles quick!

Appellation:
DOC Rioja

Country of Origin:
Spain

Type:
Red/Rouge (Still)

Grape Varities:
40% Tempranillo, 40% Garnacha, 10% Mazuelo,10% Graciano

Use of Oak:
100% new French Allier oak

Ageing:
12 months

Age of Vines:
55 years

Production:
4385 bottles

Tasting Notes:
Dark cherry in colour, the nose is incredibly full, complex and enticing with rich, spicy notes, wild black fruits, toast, leather and Provencal herbs. The mouth is full of dark fruits, excellent volume and strong, smooth tannins that combine perfectly with the oak, spices and enough acidity to give great freshness and a lengthy finish. This wine will evolve well over the next few years, but as equally rewarding to drink now with stronger meat dishes.

ROBERT PARKER, AUG 2012 – SCORE 92/100

“The 2009 Valsalado is a blend of 40% Tempranillo, 40% Garnacha, 10% Graciano and 10% Mazuelo from a 1.3-hectare vineyard outside Lagrono. It has a clean, floral bouquet with lavender infusing the peppery, dark cherry fruit. The palate is medium-bodied with a slight chewy entry. But it is well-balanced with good acidity with a vibrant, feminine finish armed with pure red, earthy fruits. This is well worth seeking out. Drink now-2018.”


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