Option A — Phase 4: Admin Settings Screen & Chat Widget
This document is the detailed plan for Phase 4 of AppiFire AI Chat (Option A). Prerequisites: Phase 3 complete (chat API working and returning replies).
Phase 4 objective
Section titled “Phase 4 objective”Build two things:
- Admin settings screen — embedded in Shopify Admin so merchants can configure the chat widget (colours, messages, knowledge sources, etc.).
- Chat widget — a Theme App Extension that merchants add to their storefront with one click; it calls your chat API.
PART A: Admin Settings Screen
Section titled “PART A: Admin Settings Screen”The live Settings page (app/routes/app.settings.jsx) includes a Store contact section (Store name, Store email, Store phone) populated from Shopify where possible; see Phase 10 — Register users for details. The sections below cover widget appearance, visibility, and credits.
A.1 Add settings columns to the shops table
Section titled “A.1 Add settings columns to the shops table”Create a new Prisma migration to add widget configuration columns:
// prisma/schema.prisma — add to the Shop modelmodel Shop { // ... existing fields ... widgetTitle String? @default("Chat with us") @map("widget_title") welcomeMessage String? @default("Hi! How can I help you today?") @map("welcome_message") brandColor String? @default("#5C6AC4") @map("brand_color") bubblePosition String? @default("right") @map("bubble_position") // "left" | "right" aiModel String? @map("ai_model") // override per shop if needed showReplyCount Boolean @default(false) @map("show_reply_count") widgetEnabled Boolean @default(true) @map("widget_enabled") // when false, chat widget hidden on storefront widgetShowOnAllPages Boolean @default(true) @map("widget_show_on_all_pages") // reserved; widget is auto-added to all pages on install // Credits & spending (paid plans). Note: daily spend limit is not enforced; column exists but is unused. dailySpendLimitCents Int? @map("daily_spend_limit_cents") // reserved; not used (no daily cap) emailAlertOnMinBalance Boolean @default(false) @map("email_alert_on_min_balance") minBalanceForAlertReplies Int? @map("min_balance_for_alert_replies") // send email when remaining replies ≤ this lastLowBalanceAlertAt DateTime? @map("last_low_balance_alert_at") // avoid sending alert more than once per day}Run:
cd appifire-ai-chatnpx prisma migrate dev --name add_shop_widget_settingsA.2 Create the settings route
Section titled “A.2 Create the settings route”File: app/routes/app.settings.jsx
This is an embedded Remix route that renders inside Shopify Admin using Polaris web components.
import { useLoaderData, useActionData, Form, useNavigation } from "react-router";import { authenticate } from "../shopify.server";import db from "../db.server";
export const loader = async ({ request }) => { const { session } = await authenticate.admin(request);
const shop = await db.shop.findFirst({ where: { shopDomain: session.shop }, select: { widgetTitle: true, welcomeMessage: true, brandColor: true, bubblePosition: true, showReplyCount: true, widgetEnabled: true, widgetShowOnAllPages: true, dailySpendLimitCents: true, emailAlertOnMinBalance: true, minBalanceForAlertReplies: true, lastLowBalanceAlertAt: true, }, });
return { settings: shop ?? {} };};
export const action = async ({ request }) => { const { session } = await authenticate.admin(request); const formData = await request.formData();
const updates = { widgetTitle: formData.get("widgetTitle") || "Chat with us", welcomeMessage: formData.get("welcomeMessage") || "Hi! How can I help you today?", brandColor: formData.get("brandColor") || "#5C6AC4", bubblePosition: formData.get("bubblePosition") || "right", showReplyCount: formData.get("showReplyCount") === "true", dailySpendLimitCents: formData.get("dailySpendLimit") ? Math.round(parseFloat(formData.get("dailySpendLimit")) * 100) : null, emailAlertOnMinBalance: formData.get("emailAlertOnMinBalance") === "true", minBalanceForAlertReplies: formData.get("minBalanceForAlertReplies") ? parseInt(formData.get("minBalanceForAlertReplies"), 10) : null, };
await db.shop.updateMany({ where: { shopDomain: session.shop }, data: updates, });
return { success: true };};
export default function SettingsPage() { const { settings } = useLoaderData(); const actionData = useActionData(); const nav = useNavigation(); const saving = nav.state === "submitting";
return ( <s-page heading="Chat Widget Settings"> {actionData?.success && ( <s-banner status="success">Settings saved successfully.</s-banner> )}
<Form method="post"> <s-card> <s-section heading="Widget Appearance"> <s-form-layout> <s-text-field name="widgetTitle" label="Widget title" defaultValue={settings.widgetTitle ?? "Chat with us"} helpText="Shown at the top of the chat window." /> <s-text-field name="welcomeMessage" label="Welcome message" multiline="3" defaultValue={settings.welcomeMessage ?? "Hi! How can I help you today?"} helpText="First message visitors see when they open the chat." /> <s-text-field name="brandColor" label="Brand colour (hex)" defaultValue={settings.brandColor ?? "#5C6AC4"} helpText="e.g. #5C6AC4 — used for the chat bubble and header." pattern="^#[0-9A-Fa-f]{6}$" /> <s-select name="bubblePosition" label="Chat bubble position" defaultValue={settings.bubblePosition ?? "right"} options={JSON.stringify([ { label: "Bottom right", value: "right" }, { label: "Bottom left", value: "left" }, ])} /> </s-form-layout> </s-section>
<s-section heading="Visibility"> <s-form-layout> <s-checkbox name="widgetEnabled" label="Enable chat widget on storefront" value="true" checked={settings.widgetEnabled !== false} helpText="The widget is added to all pages automatically when you install. Turn this off to hide the chat bubble temporarily without uninstalling the app." /> </s-form-layout> </s-section>
<s-section heading="Behaviour"> <s-form-layout> <s-checkbox name="showReplyCount" label="Show remaining replies to customers" value="true" checked={settings.showReplyCount} helpText="Displays 'X replies remaining this month' inside the widget." /> </s-form-layout> </s-section>
<s-section heading="Credits & spending"> <s-form-layout> <s-text-field name="dailySpendLimit" type="number" label="Daily spend limit (USD)" defaultValue={settings.dailySpendLimitCents != null ? (settings.dailySpendLimitCents / 100).toString() : ""} helpText="Maximum amount to spend on AI replies per day (e.g. 10 = $10). Leave empty for no limit." min="0" step="1" /> <s-checkbox name="emailAlertOnMinBalance" label="Send email alert on minimum balance" value="true" checked={settings.emailAlertOnMinBalance} helpText="Get an email when your remaining reply balance falls at or below the threshold below." /> <s-text-field name="minBalanceForAlertReplies" type="number" label="Alert when remaining replies are at or below" defaultValue={settings.minBalanceForAlertReplies ?? ""} helpText="Send alert when remaining replies (monthly + add-on) are at or below this number. Only used if email alert is on." min="0" disabled={!settings.emailAlertOnMinBalance} /> </s-form-layout> </s-section>
<s-section> <s-button type="submit" variant="primary" loading={saving} > Save settings </s-button> </s-section> </s-card> </Form> </s-page> );}A.3 Add Settings to the nav
Section titled “A.3 Add Settings to the nav”In app/routes/app.jsx, add a settings nav link:
<s-app-nav> <s-link href="/app">Home</s-link> <s-link href="/app/settings">Settings</s-link> <s-link href="/app/billing">Billing</s-link></s-app-nav>A.4 Expose settings to the widget via API
Section titled “A.4 Expose settings to the widget via API”The chat widget needs to fetch widget settings (colour, title, etc.) without requiring Shopify auth. Create a public endpoint:
File: app/routes/api.widget-settings.jsx
import db from "../db.server";import { cors } from "../lib/cors.server";
export const loader = async ({ request }) => { const url = new URL(request.url); const shopDomain = url.searchParams.get("shop");
if (!shopDomain) { return cors(new Response(JSON.stringify({ error: "shop param required" }), { status: 400 }), request); }
const shop = await db.shop.findFirst({ where: { shopDomain, status: "active" }, select: { widgetTitle: true, welcomeMessage: true, brandColor: true, bubblePosition: true, showReplyCount: true, widgetEnabled: true, widgetShowOnAllPages: true, }, });
if (!shop) { return cors(new Response(JSON.stringify({ error: "not found" }), { status: 404 }), request); }
return cors( new Response(JSON.stringify(shop), { status: 200, headers: { "Content-Type": "application/json" } }), request );};PART B: Chat Widget (Theme App Extension)
Section titled “PART B: Chat Widget (Theme App Extension)”B.1 Generate the extension
Section titled “B.1 Generate the extension”In the appifire-ai-chat/ directory:
shopify app generate extensionWhen prompted:
- Type of extension: Theme app extension
- Extension name:
appifire-chat
This creates extensions/appifire-chat/ with this structure:
extensions/appifire-chat/ blocks/ chat-widget.liquid ← section block (also injected into all templates on install) assets/ chat-widget.js ← your widget JavaScript locales/ en.default.json ← optional: translatable strings shopify.extension.tomlB.2 shopify.extension.toml
Section titled “B.2 shopify.extension.toml”api_version = "2026-04"name = "AppiFire AI Chat Widget"
[[extensions]]type = "theme"name = "AppiFire AI Chat Widget"handle = "appifire-chat"
[extensions.targeting] target = "section"B.3 blocks/chat-widget.liquid (section)
Section titled “B.3 blocks/chat-widget.liquid (section)”This Liquid file is the block that gets injected into every JSON template on install (see Theme widget injection below). Merchants can also add it manually via Theme Editor → Add section → Apps → AppiFire Chat.
{% comment %} AppiFire AI Chat Widget block {% endcomment %}
<div id="appifire-chat-root"></div>
<script> window.AppifireChat = { shopDomain: {{ shop.permanent_domain | json }}, apiUrl: {{ block.settings.api_url | json }}, settings: { widgetTitle: {{ block.settings.widget_title | default: 'Chat with us' | json }}, welcomeMessage:{{ block.settings.welcome_message | default: 'Hi! How can I help you today?' | json }}, brandColor: {{ block.settings.brand_color | default: '#5C6AC4' | json }}, position: {{ block.settings.bubble_position | default: 'right' | json }}, } };</script><script src="{{ 'chat-widget.js' | asset_url }}" defer></script>
{% schema %}{ "name": "AppiFire Chat", "target": "section", "settings": [ { "type": "text", "id": "api_url", "label": "API URL", "default": "https://ai-chat.appifire.com", "info": "Your AppiFire backend URL. Do not change unless instructed." }, { "type": "text", "id": "widget_title", "label": "Widget title", "default": "Chat with us" }, { "type": "text", "id": "welcome_message", "label": "Welcome message", "default": "Hi! How can I help you today?" }, { "type": "color", "id": "brand_color", "label": "Brand colour", "default": "#5C6AC4" }, { "type": "select", "id": "bubble_position", "label": "Bubble position", "default": "right", "options": [ { "value": "right", "label": "Bottom right" }, { "value": "left", "label": "Bottom left" } ] } ]}{% endschema %}B.3b Theme widget injection (automatic — all pages)
Section titled “B.3b Theme widget injection (automatic — all pages)”The widget is automatically added to all pages when the merchant installs the app. No need to enable an app embed or add a section manually.
- Implementation:
app/lib/theme-widget.server.jsexportsaddWidgetToAllTemplates(session). It uses the Theme REST API (read_themes,write_themesscopes) to get the shop’s main theme, list alltemplates/*.jsonassets, and inject an apps section containing the AppiFire Chat block into each template. It is called fire-and-forget fromafterAuthinshopify.server.js. - Scope: The app must request
write_themesin addition toread_themesinshopify.app.toml. App Store apps that edit theme assets may need to request protected scope; see Shopify’s Asset resource docs. - Idempotent: The code skips templates that already contain the
appifire_chatsection, so re-running (e.g. on re-auth) does not duplicate.
B.4 assets/chat-widget.js
Section titled “B.4 assets/chat-widget.js”This is a self-contained vanilla JS widget. It injects its own HTML and CSS so no external dependencies are required. Before initialising, it fetches widget settings from the API and skips rendering if Enable chat widget on storefront is off (widgetEnabled === false).
(function () { 'use strict';
const cfg = window.AppifireChat || {}; const API_URL = cfg.apiUrl || 'https://ai-chat.appifire.com'; const SHOP = cfg.shopDomain || ''; const TITLE = (cfg.settings && cfg.settings.widgetTitle) || 'Chat with us'; const WELCOME = (cfg.settings && cfg.settings.welcomeMessage) || 'Hi! How can I help you today?'; const COLOR = (cfg.settings && cfg.settings.brandColor) || '#5C6AC4'; const POSITION = (cfg.settings && cfg.settings.position) || 'right';
// ── Styles ────────────────────────────────────────────────────────────────── const css = ` #apfc-bubble { position: fixed; bottom: 24px; ${POSITION}: 24px; z-index: 9999; width: 56px; height: 56px; border-radius: 50%; background: ${COLOR}; cursor: pointer; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 12px rgba(0,0,0,0.2); border: none; outline: none; } #apfc-bubble svg { width: 26px; height: 26px; fill: #fff; } #apfc-window { position: fixed; bottom: 92px; ${POSITION}: 24px; z-index: 9999; width: 360px; max-height: 540px; background: #fff; border-radius: 16px; box-shadow: 0 8px 30px rgba(0,0,0,0.15); display: flex; flex-direction: column; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; overflow: hidden; } #apfc-window.hidden { display: none; } #apfc-header { background: ${COLOR}; color: #fff; padding: 14px 16px; font-weight: 600; font-size: 15px; display: flex; justify-content: space-between; align-items: center; } #apfc-close { background: none; border: none; color: #fff; font-size: 22px; cursor: pointer; line-height: 1; } #apfc-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 10px; } .apfc-msg { max-width: 80%; padding: 10px 14px; border-radius: 14px; font-size: 14px; line-height: 1.5; } .apfc-msg.user { background: ${COLOR}; color: #fff; align-self: flex-end; border-bottom-right-radius: 4px; } .apfc-msg.bot { background: #f1f1f1; color: #222; align-self: flex-start; border-bottom-left-radius: 4px; } .apfc-msg.typing { color: #888; font-style: italic; } #apfc-footer { padding: 12px; border-top: 1px solid #eee; display: flex; gap: 8px; } #apfc-input { flex: 1; padding: 10px 14px; border: 1px solid #ddd; border-radius: 24px; font-size: 14px; outline: none; } #apfc-input:focus { border-color: ${COLOR}; } #apfc-send { background: ${COLOR}; color: #fff; border: none; border-radius: 50%; width: 40px; height: 40px; cursor: pointer; display: flex; align-items: center; justify-content: center; } #apfc-send svg { width: 18px; height: 18px; fill: #fff; } #apfc-branding { text-align: center; padding: 6px; font-size: 11px; color: #aaa; } `;
const style = document.createElement('style'); style.textContent = css; document.head.appendChild(style);
// ── HTML ──────────────────────────────────────────────────────────────────── const bubble = document.createElement('button'); bubble.id = 'apfc-bubble'; bubble.innerHTML = `<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/></svg>`; document.body.appendChild(bubble);
const win = document.createElement('div'); win.id = 'apfc-window'; win.classList.add('hidden'); win.innerHTML = ` <div id="apfc-header"> <span>${TITLE}</span> <button id="apfc-close">✕</button> </div> <div id="apfc-messages"></div> <div id="apfc-footer"> <input id="apfc-input" type="text" placeholder="Type a message…" autocomplete="off" /> <button id="apfc-send"><svg viewBox="0 0 24 24"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg></button> </div> <div id="apfc-branding">Powered by AppiFire</div> `; document.body.appendChild(win);
// ── State ─────────────────────────────────────────────────────────────────── let sessionId = null; const msgs = win.querySelector('#apfc-messages'); const input = win.querySelector('#apfc-input');
function addMessage(text, type) { const el = document.createElement('div'); el.className = `apfc-msg ${type}`; el.textContent = text; msgs.appendChild(el); msgs.scrollTop = msgs.scrollHeight; return el; }
// Welcome message on open function openChat() { win.classList.remove('hidden'); if (msgs.children.length === 0) { addMessage(WELCOME, 'bot'); } input.focus(); }
async function sendMessage() { const text = input.value.trim(); if (!text) return; input.value = ''; addMessage(text, 'user');
const typing = addMessage('Typing…', 'bot typing');
try { const res = await fetch(`${API_URL}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ shopDomain: SHOP, visitorId: getVisitorId(), sessionId, message: text }), }); const data = await res.json(); typing.remove();
if (!res.ok) { addMessage(data.error || 'Something went wrong. Please try again.', 'bot'); return; }
sessionId = data.sessionId; addMessage(data.reply, 'bot'); } catch (err) { typing.remove(); addMessage('Connection error. Please try again.', 'bot'); } }
function getVisitorId() { let vid = sessionStorage.getItem('apfc_vid'); if (!vid) { vid = 'v_' + Math.random().toString(36).slice(2); sessionStorage.setItem('apfc_vid', vid); } return vid; }
// ── Events ────────────────────────────────────────────────────────────────── bubble.addEventListener('click', () => { win.classList.toggle('hidden'); if (!win.classList.contains('hidden')) openChat(); }); win.querySelector('#apfc-close').addEventListener('click', () => win.classList.add('hidden')); win.querySelector('#apfc-send').addEventListener('click', sendMessage); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') sendMessage(); });})();B.5 How merchants see the widget
Section titled “B.5 How merchants see the widget”- On install: The app automatically adds the chat widget section to every JSON template (index, product, collection, etc.) via the Theme API. The chat bubble appears on all pages without any steps in the theme editor.
- Disable temporarily: In app Settings → Visibility, turn off Enable chat widget on storefront. The bubble is hidden until you turn it back on. No need to remove the section or change the theme.
- Optional: Merchants can still add or remove the AppiFire Chat section manually in Theme Editor → Add section → Apps → AppiFire Chat if they want the widget only on specific templates.
No code changes or “App embeds” step required.
PART C: Connecting settings between admin and widget
Section titled “PART C: Connecting settings between admin and widget”The flow:
- Merchant saves settings in the admin settings screen → stored in the
shopstable. - The Liquid block has its own settings in the Theme Editor (redundant but convenient for quick theme-editor adjustments).
- The widget JS can also call
GET /api/widget-settings?shop=domainon load to fetch the latest DB settings and override the Liquid defaults — useful if you want a single source of truth in the DB.
Checklist
Section titled “Checklist”Admin settings screen:
-
npx prisma migrate dev --name add_shop_widget_settingsrun; new columns inshopstable (widget + credits & spending:dailySpendLimitCents,emailAlertOnMinBalance,minBalanceForAlertReplies,lastLowBalanceAlertAt). - Additional columns:
widgetEnabled(default true),widgetShowOnAllPages(reserved). -
app/routes/app.settings.jsxcreated; loads settings vialoader, saves viaaction; includes Visibility section (Enable chat widget on storefront only). Widget is auto-added to all pages on install. - “Settings” nav link added in
app/routes/app.jsx. -
app/routes/api.widget-settings.jsxcreated (public GET, CORS enabled); returnswidgetEnabled. (Do not expose credits/spending fields to the widget.) -
app/lib/theme-widget.server.jsimplementsaddWidgetToAllTemplates(session); called fromafterAuth. App requestswrite_themesscope. - Open app in Shopify Admin, change settings, save, reload — settings persist.
Theme App Extension:
-
shopify app generate extensionrun;extensions/appifire-chat/folder created. -
blocks/chat-widget.liquidwritten (section block). -
assets/chat-widget.jswritten (self-contained widget; respectswidgetEnabledfrom API before init). -
shopify app deployorshopify app devto sync extension to Shopify. - On install, widget is auto-added to all JSON templates via Theme API; no App embeds or manual section add required.
- Turning off “Enable chat widget on storefront” hides the widget without removing the section.
- Widget sends message → chat API returns reply → message displayed in widget.
- Widget handles errors (429 limit exceeded, network error).