Skip to content

Pre-Chat User Identity Form

Implementation plan for pre-chat identity capture, Shopify customer autofill, localStorage persistence, and guest-mode controls.

Goal: Before the chat window opens, collect the visitor’s name, email, and optional phone number. If the visitor is a logged-in Shopify customer, their details are auto-filled from Shopify — no form is shown. Details are cached in localStorage so returning visitors are never asked again. Visitors without stored identity can skip the form (guest mode). Guest mode can be toggled on/off by the store admin in the Settings screen.


1. Identity Resolution Priority (Widget Logic)

Section titled “1. Identity Resolution Priority (Widget Logic)”

The widget resolves visitor identity in this order before opening the chat:

1. Shopify logged-in customer
└─ Liquid injects customer data into window.AppifireChat.customer
└─ If present → skip form → update localStorage cache → start chat
2. localStorage cache (from a previous visit or form fill)
└─ apfc_identity_v key exists → skip form → start chat
└─ Check: if Shopify customer ID changed (different account), overwrite cache
3. Pre-chat form
└─ preChatFormEnabled = true → show form
├─ User fills name + email → save to localStorage → start chat
└─ User clicks "Continue as Guest" (if guestChatAllowed) → save guest flag → start chat
└─ preChatFormEnabled = false → skip form, treat as guest silently → start chat

Case A — Logged-in Shopify customer (form never shown)

Section titled “Case A — Logged-in Shopify customer (form never shown)”
User opens chat → Shopify customer data injected by Liquid
→ Chat opens directly with: "Hi Ali! How can I help you today?"

Case B — Returning visitor with localStorage cache

Section titled “Case B — Returning visitor with localStorage cache”
User opens chat on a new tab or new browser session
→ localStorage has previous identity
→ Chat opens directly (no form shown)

Case C — New visitor, form enabled, guest allowed

Section titled “Case C — New visitor, form enabled, guest allowed”
┌─────────────────────────────┐
│ Chat with us ✕ │
├─────────────────────────────┤
│ Name * │
│ [____________________] │
│ Email * │
│ [____________________] │
│ Phone (optional) │
│ [____________________] │
│ │
│ [ Start Chat ] │
│ [ Continue as Guest ] │
└─────────────────────────────┘

Case D — New visitor, form enabled, guest NOT allowed (admin toggle off)

Section titled “Case D — New visitor, form enabled, guest NOT allowed (admin toggle off)”

Same form but no “Continue as Guest” button — visitor must fill name + email.

Case E — Form disabled by admin (preChatFormEnabled = false)

Section titled “Case E — Form disabled by admin (preChatFormEnabled = false)”

Form is skipped entirely. All sessions created as guest silently.


localStorage keys (persist across tabs and browser restarts)

Section titled “localStorage keys (persist across tabs and browser restarts)”
KeyValueDescription
apfc_identity_vJSON stringCached identity object (see shape below)

Identity object shape:

{
"name": "Ali Agha",
"email": "ali@example.com",
"phone": "+92 300 1234567",
"isGuest": false,
"source": "shopify",
"customerId": "gid://shopify/Customer/12345",
"savedAt": 1711234567890
}
  • source: "shopify" | "form" | "guest"
  • customerId: Shopify customer GID — used to detect a different account logging in and invalidate the cache
  • savedAt: Unix timestamp in ms — for future expiry logic if needed
KeyValueDescription
apfc_vidstringVisitor ID — per-tab as before

The apfc_vid stays in sessionStorage (intentionally per-tab). Identity moves to localStorage.


ColumnTypeDefaultDescription
visitor_nameString?nullName from form or Shopify customer
visitor_emailString?nullEmail from form or Shopify customer
visitor_phoneString?nullPhone from form or Shopify customer
is_guestBooleantruetrue = skipped/no identity; false = identified
ColumnTypeDefaultDescription
pre_chat_form_enabledBooleantrueWhether to show the pre-chat form at all
guest_chat_allowedBooleantrueWhether “Continue as Guest” is shown

FileTypeWhat changes
prisma/schema.prismaModifyAdd new columns to ChatSession and Shop
prisma/migrations/...NewAuto-generated migration
extensions/appifire-chat/blocks/chat-widget.liquidModifyInject Shopify customer data into window.AppifireChat.customer
extensions/appifire-chat/blocks/chat-widget-embed.liquidModifySame customer injection for embed version
app/routes/api.widget-settings.jsxModifyReturn preChatFormEnabled + guestChatAllowed
app/routes/api.chat.jsxModifyAccept visitorName, visitorEmail, visitorPhone, isGuest in body
app/lib/rag.server.jsModifySet identity fields on session create; load from session for prompt on turn 2+
app/lib/prompt-builder.server.jsModifyInject visitor name into system prompt
app/routes/app.settings.jsxModifyAdd admin toggles for form and guest mode
app/routes/app.chat-logs.jsxModifyShow visitor name/email/source in chat logs
extensions/appifire-chat/assets/chat-widget.jsModifyFull identity resolution logic + pre-chat form UI

// ChatSession — add after metadata field:
visitorName String? @map("visitor_name")
visitorEmail String? @map("visitor_email")
visitorPhone String? @map("visitor_phone")
isGuest Boolean @default(true) @map("is_guest")
// Shop — add in Widget & appearance section:
preChatFormEnabled Boolean @default(true) @map("pre_chat_form_enabled")
guestChatAllowed Boolean @default(true) @map("guest_chat_allowed")

Run after schema change:

Terminal window
npx prisma migrate dev --name add_pre_chat_identity
npx prisma generate

6.2 Both Liquid files — inject Shopify customer data

Section titled “6.2 Both Liquid files — inject Shopify customer data”

extensions/appifire-chat/blocks/chat-widget.liquid and chat-widget-embed.liquid — add customer block using Liquid’s built-in customer object (only populated when visitor is logged into their Shopify account):

<script>
window.AppifireChat = {
shopDomain: {{ shop.permanent_domain | json }},
apiUrl: {{ 'https://ai-chat.appifire.com' | json }},
settings: {},
{% if customer %}
customer: {
id: {{ customer.id | json }},
name: {{ customer.name | json }},
email: {{ customer.email | json }},
phone: {{ customer.phone | default: '' | json }}
}
{% else %}
customer: null
{% endif %}
};
</script>

Why this works: Shopify renders this Liquid server-side on every storefront page load. When a customer is logged in, {{ customer }} is truthy and their data is injected. When not logged in, customer is null. The widget JS reads window.AppifireChat.customer.

Security note: This data is already visible to the customer (it’s their own data) and is not a secret — it’s the same as what Shopify shows in the /account page.


Add to the DB select and JSON response:

const shop = await db.shop.findFirst({
where: { shopDomain, status: "active" },
select: {
widgetTitle: true,
welcomeMessage: true,
brandColor: true,
bubblePosition: true,
widgetEnabled: true,
preChatFormEnabled: true, // ← NEW
guestChatAllowed: true, // ← NEW
},
});

The widget already calls this endpoint on startup and stores the settings — no further widget-side plumbing needed.


// Add to body parsing:
const visitorName = body.visitorName ?? null;
const visitorEmail = body.visitorEmail ?? null;
const visitorPhone = body.visitorPhone ?? null;
const isGuest = body.isGuest !== false; // default true if not sent
// Pass to generateChatReply:
const result = await generateChatReply({
shop: shopForReply,
visitorId,
sessionId: sessionId || null,
message,
orderNameOrId: orderNameOrId || null,
visitorName,
visitorEmail,
visitorPhone,
isGuest,
});

Updated function signature:

export async function generateChatReply({
shop, visitorId, sessionId, message, orderNameOrId = null,
visitorName = null, visitorEmail = null, visitorPhone = null, isGuest = true,
})

When creating a new session (turn 1, no sessionId):

session = await prisma.chatSession.create({
data: {
shopId: shop.id,
visitorId: vid,
displayNumber,
startedAt: new Date(),
visitorName: visitorName || null,
visitorEmail: visitorEmail || null,
visitorPhone: visitorPhone || null,
isGuest: isGuest,
},
});

When loading an existing session (turn 2+, sessionId provided), add identity fields to the select:

session = await prisma.chatSession.findUnique({
where: { id: sessionId },
select: {
id: true, shopId: true, metadata: true,
visitorName: true, visitorEmail: true, // ← NEW
},
});

Pass identity to buildPrompt:

const promptMessages = buildPrompt(
chunks ?? [], message, historyForPrompt,
{
orderContext,
visitorName: session.visitorName ?? null,
visitorEmail: session.visitorEmail ?? null,
}
);

Note: Identity fields are set only on session CREATE (turn 1). On subsequent turns, they are read from the session row. This means the AI always has the name, even on turn 20, without the widget resending it.


export function buildPrompt(chunks, message, history = [], opts = {}) {
const { orderContext, visitorName, visitorEmail } = opts;
let systemContent = SYSTEM_PROMPT;
// Inject visitor identity when available — helps AI greet by name and be personal.
if (visitorName) {
systemContent +=
`\n\n--- Visitor Identity ---\n` +
`The customer's name is: ${visitorName}.\n` +
(visitorEmail ? `Their email is: ${visitorEmail}.\n` : '') +
`Use their first name when greeting or addressing them. Be warm and personal.`;
}
// ...rest of existing context and message building unchanged...
}

Loader — add to select:

preChatFormEnabled: true,
guestChatAllowed: true,

Action — handle new fields:

preChatFormEnabled: formData.get("preChatFormEnabled") === "true",
guestChatAllowed: formData.get("guestChatAllowed") === "true",

UI — new card after existing widget settings card:

<Card>
<BlockStack gap="400">
<Text variant="headingMd">Pre-chat form</Text>
<Text tone="subdued">
Ask visitors for their name and email before starting a chat.
Logged-in Shopify customers are identified automatically — the form is
never shown to them. Returning visitors are identified from their browser
cache and also skip the form.
</Text>
<Checkbox
label="Show pre-chat form to unidentified visitors"
checked={settings.preChatFormEnabled}
onChange={(v) => setSettings({ ...settings, preChatFormEnabled: v })}
/>
<Checkbox
label='Allow visitors to skip the form and chat as "Guest"'
checked={settings.guestChatAllowed}
disabled={!settings.preChatFormEnabled}
onChange={(v) => setSettings({ ...settings, guestChatAllowed: v })}
/>
</BlockStack>
</Card>

6.8 extensions/appifire-chat/assets/chat-widget.js

Section titled “6.8 extensions/appifire-chat/assets/chat-widget.js”

This is the largest change. Below is the complete logic to add.

const IDENTITY_LS_KEY = 'apfc_identity_v'; // localStorage key
function loadIdentityFromStorage() {
try {
const raw = localStorage.getItem(IDENTITY_LS_KEY);
return raw ? JSON.parse(raw) : null;
} catch (e) { return null; }
}
function saveIdentityToStorage(identity) {
try {
localStorage.setItem(IDENTITY_LS_KEY, JSON.stringify({
...identity,
savedAt: Date.now(),
}));
} catch (e) {}
}
function clearIdentityFromStorage() {
try { localStorage.removeItem(IDENTITY_LS_KEY); } catch (e) {}
}

Identity resolution function (called once on widget init)

Section titled “Identity resolution function (called once on widget init)”
/**
* Resolves visitor identity from:
* 1. Shopify logged-in customer (window.AppifireChat.customer)
* 2. localStorage cache
* 3. null → show form or guest
*
* Returns: { name, email, phone, isGuest, source } or null
*/
function resolveIdentity() {
const shopifyCustomer = (window.AppifireChat || {}).customer;
if (shopifyCustomer && shopifyCustomer.email) {
const identity = {
name: shopifyCustomer.name || '',
email: shopifyCustomer.email || '',
phone: shopifyCustomer.phone || '',
isGuest: false,
source: 'shopify',
customerId: String(shopifyCustomer.id || ''),
};
// Overwrite localStorage — Shopify is the most authoritative source.
saveIdentityToStorage(identity);
return identity;
}
const cached = loadIdentityFromStorage();
if (cached) {
// If a different Shopify customer was logged in before, cached data is stale.
// (shopifyCustomer is null here, so no conflict — just return cached.)
return cached;
}
return null; // → must show form or auto-guest
}
let resolvedIdentity = null; // module-level, set once
function openChat() {
if (resolvedIdentity === null) {
// Try to resolve without showing form first.
resolvedIdentity = resolveIdentity();
}
if (resolvedIdentity) {
// Identity known — open chat directly.
showChatPanel();
} else if (fetchedSettings && fetchedSettings.preChatFormEnabled === false) {
// Admin disabled form — treat as guest silently.
resolvedIdentity = { name: '', email: '', phone: '', isGuest: true, source: 'guest' };
saveIdentityToStorage(resolvedIdentity);
showChatPanel();
} else {
// Show pre-chat form.
showPreChatForm();
}
}

Pre-chat form panel (new HTML injected into #apfc-window)

Section titled “Pre-chat form panel (new HTML injected into #apfc-window)”
function showPreChatForm() {
const guestAllowed = fetchedSettings && fetchedSettings.guestChatAllowed !== false;
// Replace the messages + footer area with the form.
const formHtml = `
<div id="apfc-prechat">
<p class="apfc-prechat-intro">Please enter your details to start chatting.</p>
<div class="apfc-prechat-field">
<label for="apfc-pc-name">Name <span aria-hidden="true">*</span></label>
<input id="apfc-pc-name" type="text" placeholder="Your name" autocomplete="name" />
<span class="apfc-pc-err" id="apfc-pc-name-err"></span>
</div>
<div class="apfc-prechat-field">
<label for="apfc-pc-email">Email <span aria-hidden="true">*</span></label>
<input id="apfc-pc-email" type="email" placeholder="you@example.com" autocomplete="email" />
<span class="apfc-pc-err" id="apfc-pc-email-err"></span>
</div>
<div class="apfc-prechat-field">
<label for="apfc-pc-phone">Phone <span class="apfc-pc-opt">(optional)</span></label>
<input id="apfc-pc-phone" type="tel" placeholder="+1 234 567 8900" autocomplete="tel" />
</div>
<button id="apfc-pc-submit">Start Chat</button>
${guestAllowed ? '<button id="apfc-pc-guest" type="button">Continue as Guest</button>' : ''}
</div>
`;
const messagesEl = document.getElementById('apfc-messages');
const footerEl = document.getElementById('apfc-footer');
if (messagesEl) messagesEl.style.display = 'none';
if (footerEl) footerEl.style.display = 'none';
// Insert form before footer (or at end of window).
const win = document.getElementById('apfc-window');
const formWrapper = document.createElement('div');
formWrapper.id = 'apfc-prechat-wrap';
formWrapper.innerHTML = formHtml;
win.appendChild(formWrapper);
// Wire up submit.
document.getElementById('apfc-pc-submit').addEventListener('click', function () {
const name = (document.getElementById('apfc-pc-name').value || '').trim();
const email = (document.getElementById('apfc-pc-email').value || '').trim();
const phone = (document.getElementById('apfc-pc-phone').value || '').trim();
let valid = true;
document.getElementById('apfc-pc-name-err').textContent = '';
document.getElementById('apfc-pc-email-err').textContent = '';
if (!name) {
document.getElementById('apfc-pc-name-err').textContent = 'Name is required.';
valid = false;
}
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
document.getElementById('apfc-pc-email-err').textContent = 'A valid email is required.';
valid = false;
}
if (!valid) return;
resolvedIdentity = { name, email, phone, isGuest: false, source: 'form' };
saveIdentityToStorage(resolvedIdentity);
removePrechatForm();
showChatPanel();
});
// Wire up guest.
var guestBtn = document.getElementById('apfc-pc-guest');
if (guestBtn) {
guestBtn.addEventListener('click', function () {
resolvedIdentity = { name: '', email: '', phone: '', isGuest: true, source: 'guest' };
saveIdentityToStorage(resolvedIdentity);
removePrechatForm();
showChatPanel();
});
}
}
function removePrechatForm() {
const wrap = document.getElementById('apfc-prechat-wrap');
if (wrap) wrap.remove();
const messagesEl = document.getElementById('apfc-messages');
const footerEl = document.getElementById('apfc-footer');
if (messagesEl) messagesEl.style.display = '';
if (footerEl) footerEl.style.display = '';
}

Sending identity with the first message only

Section titled “Sending identity with the first message only”
// In the sendMessage() function, when building the fetch body:
const isFirstMessage = (sessionId === null);
const body = {
shopDomain: SHOP,
visitorId: getVisitorId(),
sessionId: sessionId,
message: trimmed,
};
// Only send identity on turn 1 (when sessionId is null — server will create the session).
if (isFirstMessage && resolvedIdentity) {
body.visitorName = resolvedIdentity.name || null;
body.visitorEmail = resolvedIdentity.email || null;
body.visitorPhone = resolvedIdentity.phone || null;
body.isGuest = resolvedIdentity.isGuest !== false ? true : false;
}
#apfc-prechat-wrap { flex: 1; overflow-y: auto; padding: 20px 16px; display: flex; flex-direction: column; gap: 0; }
#apfc-prechat { display: flex; flex-direction: column; gap: 14px; }
.apfc-prechat-intro { font-size: 14px; color: #555; margin: 0 0 4px; }
.apfc-prechat-field { display: flex; flex-direction: column; gap: 4px; }
.apfc-prechat-field label { font-size: 13px; font-weight: 600; color: #333; }
.apfc-pc-opt { font-weight: 400; color: #999; }
.apfc-prechat-field input { padding: 10px 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; outline: none; font-family: inherit; }
.apfc-prechat-field input:focus { border-color: ${safeColor}; }
.apfc-pc-err { font-size: 12px; color: #e53935; min-height: 16px; }
#apfc-pc-submit { background: ${safeColor}; color: #fff; border: none; border-radius: 8px; padding: 12px; font-size: 14px; font-weight: 600; cursor: pointer; font-family: inherit; }
#apfc-pc-guest { background: none; border: none; color: #888; font-size: 13px; cursor: pointer; text-decoration: underline; padding: 4px 0; font-family: inherit; text-align: center; }
#apfc-pc-guest:hover { color: #555; }

Loader — add identity fields to select:

// In the sessions list query:
visitorName: true,
visitorEmail: true,
isGuest: true,

UI — show identity in the sessions list:

  • If !isGuest && visitorName: show name + email.
  • If isGuest or no name: show “Guest” badge.
  • Add a “Source” column: “Shopify login”, “Form”, “Guest” (once source field is stored — see note below).

Optional: Add a visitorSource column (shopify | form | guest) to chat_sessions to record where the identity came from. This is a nice-to-have for analytics.


7. Session Identity Rules Across All Turns

Section titled “7. Session Identity Rules Across All Turns”
TurnConditionWhat happens
Turn 1sessionId = null, identity availableSession created with visitorName, visitorEmail, visitorPhone, isGuest = false
Turn 1sessionId = null, guestSession created with isGuest = true, identity fields null
Turn 2+sessionId = "..."Existing session loaded; identity read from DB, used in prompt; widget does NOT resend identity
Any turnShopify customer logged inLiquid injects identity; widget uses it; localStorage updated
Any turnSame visitor, new tablocalStorage cache used; form skipped
Any turnDifferent Shopify accountLiquid injects new customer; localStorage overwritten; new session identity

ScenarioWhat happens
Shopify customer is logged inAlways overwrites localStorage cache with Shopify data (most authoritative)
Customer logs out of Shopifywindow.AppifireChat.customer is null; localStorage cache used until expiry or manual clear
Different customer logs in on same deviceShopify injects new customer → overwrites localStorage → new identity from next session onward
Visitor clears browser storageForm shown again on next chat open
Admin disables formAll visitors treated as guest; localStorage still written with isGuest: true so form not shown repeatedly

  • Log into Shopify storefront as a customer.
  • Open chat widget → form is not shown.
  • AI greets visitor by first name.
  • Session in DB has correct visitor_name and visitor_email.
  • localStorage key apfc_identity_v contains source: "shopify".
  • Log out of Shopify → window.AppifireChat.customer is null.
  • Reopen chat → localStorage cache used → form still not shown.
  • Fill the form on one tab → open new tab → chat opens directly, no form shown.
  • Clear browser localStorage → form shown again.
  • Form appears for unidentified visitor when preChatFormEnabled = true.
  • “Start Chat” shows validation if name or email empty.
  • Phone field is optional — no error when blank.
  • “Continue as Guest” visible when guestChatAllowed = true, hidden when false.
  • After submit: messages area appears, welcome message shown.
  • After submit: session in DB has correct fields.
  • Guest: session has is_guest = true, identity fields null.
  • Settings screen shows “Pre-chat form” and “Allow guest” toggles.
  • Disabling form: widget opens chat directly, all sessions as guest.
  • Enabling form with guest OFF: form shown, no skip button.
  • Widget-settings API returns preChatFormEnabled and guestChatAllowed.
  • AI uses visitor’s first name in the greeting.
  • On turn 5, AI still knows the visitor’s name (loaded from session row, not re-sent).
  • Guest sessions: AI does not make up a name.
  • Identified sessions show name + email.
  • Guest sessions show “Guest” badge.

FileScope
prisma/schema.prisma+4 cols on chat_sessions, +2 cols on shops
chat-widget.liquid + chat-widget-embed.liquid+8 lines Liquid (customer injection)
api.widget-settings.jsx+2 fields in select + response
api.chat.jsx+4 body fields parsed + passed
rag.server.js+4 fields on session create, load from session, pass to prompt
prompt-builder.server.js+8 lines (identity block in system prompt)
app.settings.jsx+~35 lines (two toggles + card)
app.chat-logs.jsx+~15 lines (name/email/guest badge in list)
chat-widget.jsLargest: +~150 lines (form HTML/CSS, identity resolution, localStorage, sendMessage patch)