Only active Reserve members. Once you join, your account is tagged and you'll see Reserve-exclusive bottles across the site. Non-members see the bottle but can't purchase.
How do I get member pricing?
It's automatic. Log in as a Reserve member and member prices appear at checkout. No codes needed.
When do drops happen?
Drops are unannounced by design — that's the point. Members get an email 48 hours before the bottle goes public. Check your inbox.
Can I cancel?
Monthly members can cancel any time. Annual memberships are non-refundable but you'll be notified before renewal.
What if I'm already a customer?
Your existing order history stays. Just join The Reserve and your account is upgraded immediately.
*/
(function () {
"use strict";
const SCRIPT = document.currentScript;
const API_URL = "https://ai-bartender-production.up.railway.app";
const STARTERS = [
"🥃 What are you in the mood for tonight?",
"🍽️ Planning a dinner party? Tell me about the menu.",
"🎁 Looking for a gift? Let me help.",
];
const WELCOME = "Hey there! I'm Jack — your friendly neighborhood barkeep at SpeakSpirits. What can I pour for you tonight?";
/* ── Inject CSS ── */
function injectStyles() {
const base = SCRIPT ? SCRIPT.src.replace(/bartender-widget\.js.*$/, "") : "";
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = base + "bartender-widget.css";
document.head.appendChild(link);
}
/* ── Build DOM ── */
function buildWidget() {
const wrapper = document.createElement("div");
wrapper.id = "ss-bartender-widget";
wrapper.innerHTML = `
🥃
Jack the Barkeep
SpeakSpirits.com
`;
document.body.appendChild(wrapper);
}
/* ── State ── */
let messages = []; // {role, content}
let isOpen = false;
let isStreaming = false;
/* ── DOM refs (set after build) ── */
let panel, bubble, msgContainer, input, sendBtn, startersDiv;
/* ── Helpers ── */
function scrollBottom() {
msgContainer.scrollTop = msgContainer.scrollHeight;
}
function autoResize() {
input.style.height = "auto";
input.style.height = Math.min(input.scrollHeight, 80) + "px";
}
/** Parse product JSON blocks from assistant text and return HTML */
function renderContent(text) {
// Replace ```product {...} ``` blocks with product cards
const productRe = /```product\s*(\{[\s\S]*?\})\s*```/g;
let html = text;
html = html.replace(productRe, function (_, jsonStr) {
try {
const p = JSON.parse(jsonStr);
return `
${esc(p.name)}
${esc(p.tasting_note || "")}
`;
} catch {
return "";
}
});
// Basic markdown: **bold**, *italic*, line breaks
html = html.replace(/\*\*(.+?)\*\*/g, "$1");
html = html.replace(/\*(.+?)\*/g, "$1");
html = html.replace(/\n/g, " ");
return html;
}
function esc(s) {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function addMessage(role, content, streaming) {
const div = document.createElement("div");
div.className = "ss-msg " + (role === "user" ? "ss-msg-user" : "ss-msg-bot");
div.innerHTML = renderContent(content);
if (streaming) div.setAttribute("data-streaming", "1");
msgContainer.appendChild(div);
scrollBottom();
return div;
}
function showTyping() {
const div = document.createElement("div");
div.className = "ss-typing";
div.id = "ss-typing";
div.innerHTML = "";
msgContainer.appendChild(div);
scrollBottom();
}
function hideTyping() {
const el = document.getElementById("ss-typing");
if (el) el.remove();
}
function hideStarters() {
if (startersDiv) startersDiv.style.display = "none";
}
/* ── Streaming chat ── */
async function sendMessage(text) {
if (isStreaming || !text.trim()) return;
hideStarters();
isStreaming = true;
sendBtn.disabled = true;
// User message
messages.push({ role: "user", content: text.trim() });
addMessage("user", text.trim());
input.value = "";
autoResize();
showTyping();
try {
const resp = await fetch(API_URL + "/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages }),
});
if (!resp.ok) throw new Error("API error " + resp.status);
hideTyping();
const botDiv = addMessage("assistant", "", true);
let fullText = "";
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
try {
const evt = JSON.parse(line.slice(6));
if (evt.type === "text") {
fullText += evt.text;
botDiv.innerHTML = renderContent(fullText);
scrollBottom();
} else if (evt.type === "error") {
fullText += evt.text;
botDiv.innerHTML = renderContent(fullText);
}
} catch {}
}
}
messages.push({ role: "assistant", content: fullText });
botDiv.removeAttribute("data-streaming");
} catch (err) {
hideTyping();
addMessage("assistant", "Oops — looks like I knocked over a bottle back here. Give me a sec and try again. 🍸");
console.error("[Bartender]", err);
} finally {
isStreaming = false;
sendBtn.disabled = false;
input.focus();
}
}
/* ── Toggle panel ── */
function toggle() {
isOpen = !isOpen;
panel.classList.toggle("ss-open", isOpen);
if (isOpen) input.focus();
}
/* ── Init ── */
function init() {
// CSS already inlined in theme.liquid
buildWidget();
panel = document.getElementById("ss-bartender-panel");
bubble = document.getElementById("ss-bartender-bubble");
msgContainer = document.getElementById("ss-messages");
input = document.getElementById("ss-input");
sendBtn = document.getElementById("ss-send");
startersDiv = document.getElementById("ss-starters");
// Welcome message
addMessage("assistant", WELCOME);
// Starters
STARTERS.forEach(function (text) {
const btn = document.createElement("button");
btn.className = "ss-starter-btn";
btn.textContent = text;
btn.addEventListener("click", function () { sendMessage(text); });
startersDiv.appendChild(btn);
});
// Events
bubble.addEventListener("click", toggle);
panel.querySelector(".ss-close-btn").addEventListener("click", toggle);
sendBtn.addEventListener("click", function () { sendMessage(input.value); });
input.addEventListener("keydown", function (e) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage(input.value);
}
});
input.addEventListener("input", autoResize);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
// Expose global API for product page buttons
window.BartenderWidget = {
open: function() {
if (!isOpen) toggle();
},
close: function() {
if (isOpen) toggle();
},
toggle: toggle
};
})();
*/
(function () {
"use strict";
const SCRIPT = document.currentScript;
const API_URL = "https://ai-bartender-production-475d.up.railway.app";
const STARTERS = [
"🥃 What are you in the mood for tonight?",
"🍽️ Planning a dinner party? Tell me about the menu.",
"🎁 Looking for a gift? Let me help.",
];
const WELCOME = "Hey there! I'm Jack — your friendly neighborhood barkeep at SpeakSpirits. What can I pour for you tonight?";
/* CSS already inlined in theme */
function injectStyles() { /* no-op */ }
/* ── Build DOM ── */
function buildWidget() {
const wrapper = document.createElement("div");
wrapper.id = "ss-bartender-widget";
wrapper.innerHTML = `
🥃
Jack the Barkeep
SpeakSpirits.com
`;
document.body.appendChild(wrapper);
}
/* ── State ── */
let messages = []; // {role, content}
let isOpen = false;
let isStreaming = false;
/* ── DOM refs (set after build) ── */
let panel, bubble, msgContainer, input, sendBtn, startersDiv;
/* ── Helpers ── */
function scrollBottom() {
msgContainer.scrollTop = msgContainer.scrollHeight;
}
function autoResize() {
input.style.height = "auto";
input.style.height = Math.min(input.scrollHeight, 80) + "px";
}
/** Parse product JSON blocks from assistant text and return HTML */
function renderContent(text) {
// Replace ```product {...} ``` blocks with product cards
const productRe = /```product\s*(\{[\s\S]*?\})\s*```/g;
let html = text;
html = html.replace(productRe, function (_, jsonStr) {
try {
const p = JSON.parse(jsonStr);
return `
${esc(p.name)}
${esc(p.tasting_note || "")}
`;
} catch {
return "";
}
});
// Basic markdown: **bold**, *italic*, line breaks
html = html.replace(/\*\*(.+?)\*\*/g, "$1");
html = html.replace(/\*(.+?)\*/g, "$1");
html = html.replace(/\n/g, " ");
return html;
}
function esc(s) {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function addMessage(role, content, streaming) {
const div = document.createElement("div");
div.className = "ss-msg " + (role === "user" ? "ss-msg-user" : "ss-msg-bot");
div.innerHTML = renderContent(content);
if (streaming) div.setAttribute("data-streaming", "1");
msgContainer.appendChild(div);
scrollBottom();
return div;
}
function showTyping() {
const div = document.createElement("div");
div.className = "ss-typing";
div.id = "ss-typing";
div.innerHTML = "";
msgContainer.appendChild(div);
scrollBottom();
}
function hideTyping() {
const el = document.getElementById("ss-typing");
if (el) el.remove();
}
function hideStarters() {
if (startersDiv) startersDiv.style.display = "none";
}
/* ── Streaming chat ── */
async function sendMessage(text) {
if (isStreaming || !text.trim()) return;
hideStarters();
isStreaming = true;
sendBtn.disabled = true;
// User message
messages.push({ role: "user", content: text.trim() });
addMessage("user", text.trim());
input.value = "";
autoResize();
showTyping();
try {
const resp = await fetch(API_URL + "/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages }),
});
if (!resp.ok) throw new Error("API error " + resp.status);
hideTyping();
const botDiv = addMessage("assistant", "", true);
let fullText = "";
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
try {
const evt = JSON.parse(line.slice(6));
if (evt.type === "text") {
fullText += evt.text;
botDiv.innerHTML = renderContent(fullText);
scrollBottom();
} else if (evt.type === "error") {
fullText += evt.text;
botDiv.innerHTML = renderContent(fullText);
}
} catch {}
}
}
messages.push({ role: "assistant", content: fullText });
botDiv.removeAttribute("data-streaming");
} catch (err) {
hideTyping();
addMessage("assistant", "Oops — looks like I knocked over a bottle back here. Give me a sec and try again. 🍸");
console.error("[Bartender]", err);
} finally {
isStreaming = false;
sendBtn.disabled = false;
input.focus();
}
}
/* ── Toggle panel ── */
function toggle() {
isOpen = !isOpen;
panel.classList.toggle("ss-open", isOpen);
if (isOpen) input.focus();
}
/* ── Init ── */
function init() {
injectStyles();
buildWidget();
panel = document.getElementById("ss-bartender-panel");
bubble = document.getElementById("ss-bartender-bubble");
msgContainer = document.getElementById("ss-messages");
input = document.getElementById("ss-input");
sendBtn = document.getElementById("ss-send");
startersDiv = document.getElementById("ss-starters");
// Welcome message
addMessage("assistant", WELCOME);
// Starters
STARTERS.forEach(function (text) {
const btn = document.createElement("button");
btn.className = "ss-starter-btn";
btn.textContent = text;
btn.addEventListener("click", function () { sendMessage(text); });
startersDiv.appendChild(btn);
});
// Events
bubble.addEventListener("click", toggle);
panel.querySelector(".ss-close-btn").addEventListener("click", toggle);
sendBtn.addEventListener("click", function () { sendMessage(input.value); });
input.addEventListener("keydown", function (e) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage(input.value);
}
});
input.addEventListener("input", autoResize);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();