Skip to content

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).


Build two things:

  1. Admin settings screen — embedded in Shopify Admin so merchants can configure the chat widget (colours, messages, knowledge sources, etc.).
  2. Chat widget — a Theme App Extension that merchants add to their storefront with one click; it calls your chat API.

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 model
model 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:

Terminal window
cd appifire-ai-chat
npx prisma migrate dev --name add_shop_widget_settings

File: app/routes/app.settings.jsx

This is an embedded Remix route that renders inside Shopify Admin using Polaris web components.

app/routes/app.settings.jsx
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>
);
}

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>

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

In the appifire-ai-chat/ directory:

Terminal window
shopify app generate extension

When 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.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"

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.js exports addWidgetToAllTemplates(session). It uses the Theme REST API (read_themes, write_themes scopes) to get the shop’s main theme, list all templates/*.json assets, and inject an apps section containing the AppiFire Chat block into each template. It is called fire-and-forget from afterAuth in shopify.server.js.
  • Scope: The app must request write_themes in addition to read_themes in shopify.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_chat section, so re-running (e.g. on re-auth) does not duplicate.

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">&#x2715;</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(); });
})();
  • 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 SettingsVisibility, 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:

  1. Merchant saves settings in the admin settings screen → stored in the shops table.
  2. The Liquid block has its own settings in the Theme Editor (redundant but convenient for quick theme-editor adjustments).
  3. The widget JS can also call GET /api/widget-settings?shop=domain on load to fetch the latest DB settings and override the Liquid defaults — useful if you want a single source of truth in the DB.

Admin settings screen:

  • npx prisma migrate dev --name add_shop_widget_settings run; new columns in shops table (widget + credits & spending: dailySpendLimitCents, emailAlertOnMinBalance, minBalanceForAlertReplies, lastLowBalanceAlertAt).
  • Additional columns: widgetEnabled (default true), widgetShowOnAllPages (reserved).
  • app/routes/app.settings.jsx created; loads settings via loader, saves via action; 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.jsx created (public GET, CORS enabled); returns widgetEnabled. (Do not expose credits/spending fields to the widget.)
  • app/lib/theme-widget.server.js implements addWidgetToAllTemplates(session); called from afterAuth. App requests write_themes scope.
  • Open app in Shopify Admin, change settings, save, reload — settings persist.

Theme App Extension:

  • shopify app generate extension run; extensions/appifire-chat/ folder created.
  • blocks/chat-widget.liquid written (section block).
  • assets/chat-widget.js written (self-contained widget; respects widgetEnabled from API before init).
  • shopify app deploy or shopify app dev to 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).

Next: Option-A-Phase-5-Billing-and-Usage.md