Pre-Chat User Identity Form
Implementation plan for pre-chat identity capture, Shopify customer autofill, localStorage persistence, and guest-mode controls.
Phase 18 — Pre-Chat User Identity Form
Section titled “Phase 18 — Pre-Chat User Identity Form”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 chat2. User-Facing Behaviour
Section titled “2. User-Facing Behaviour”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.
3. Data & State Design
Section titled “3. Data & State Design”localStorage keys (persist across tabs and browser restarts)
Section titled “localStorage keys (persist across tabs and browser restarts)”| Key | Value | Description |
|---|---|---|
apfc_identity_v | JSON string | Cached 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 cachesavedAt: Unix timestamp in ms — for future expiry logic if needed
sessionStorage keys (kept as-is)
Section titled “sessionStorage keys (kept as-is)”| Key | Value | Description |
|---|---|---|
apfc_vid | string | Visitor ID — per-tab as before |
The apfc_vid stays in sessionStorage (intentionally per-tab). Identity moves to localStorage.
4. Database Changes
Section titled “4. Database Changes”chat_sessions table — 4 new columns
Section titled “chat_sessions table — 4 new columns”| Column | Type | Default | Description |
|---|---|---|---|
visitor_name | String? | null | Name from form or Shopify customer |
visitor_email | String? | null | Email from form or Shopify customer |
visitor_phone | String? | null | Phone from form or Shopify customer |
is_guest | Boolean | true | true = skipped/no identity; false = identified |
shops table — 2 new columns
Section titled “shops table — 2 new columns”| Column | Type | Default | Description |
|---|---|---|---|
pre_chat_form_enabled | Boolean | true | Whether to show the pre-chat form at all |
guest_chat_allowed | Boolean | true | Whether “Continue as Guest” is shown |
5. Files to Change
Section titled “5. Files to Change”| File | Type | What changes |
|---|---|---|
prisma/schema.prisma | Modify | Add new columns to ChatSession and Shop |
prisma/migrations/... | New | Auto-generated migration |
extensions/appifire-chat/blocks/chat-widget.liquid | Modify | Inject Shopify customer data into window.AppifireChat.customer |
extensions/appifire-chat/blocks/chat-widget-embed.liquid | Modify | Same customer injection for embed version |
app/routes/api.widget-settings.jsx | Modify | Return preChatFormEnabled + guestChatAllowed |
app/routes/api.chat.jsx | Modify | Accept visitorName, visitorEmail, visitorPhone, isGuest in body |
app/lib/rag.server.js | Modify | Set identity fields on session create; load from session for prompt on turn 2+ |
app/lib/prompt-builder.server.js | Modify | Inject visitor name into system prompt |
app/routes/app.settings.jsx | Modify | Add admin toggles for form and guest mode |
app/routes/app.chat-logs.jsx | Modify | Show visitor name/email/source in chat logs |
extensions/appifire-chat/assets/chat-widget.js | Modify | Full identity resolution logic + pre-chat form UI |
6. Detailed Changes Per File
Section titled “6. Detailed Changes Per File”6.1 prisma/schema.prisma
Section titled “6.1 prisma/schema.prisma”// 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:
npx prisma migrate dev --name add_pre_chat_identitynpx prisma generate6.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.
6.3 app/routes/api.widget-settings.jsx
Section titled “6.3 app/routes/api.widget-settings.jsx”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.
6.4 app/routes/api.chat.jsx
Section titled “6.4 app/routes/api.chat.jsx”// 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,});6.5 app/lib/rag.server.js
Section titled “6.5 app/lib/rag.server.js”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.
6.6 app/lib/prompt-builder.server.js
Section titled “6.6 app/lib/prompt-builder.server.js”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...}6.7 app/routes/app.settings.jsx
Section titled “6.7 app/routes/app.settings.jsx”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.
New constants / helpers
Section titled “New constants / helpers”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}Updated openChat() flow
Section titled “Updated openChat() flow”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;}CSS additions for the pre-chat form
Section titled “CSS additions for the pre-chat form”#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; }6.9 app/routes/app.chat-logs.jsx
Section titled “6.9 app/routes/app.chat-logs.jsx”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
isGuestor no name: show “Guest” badge. - Add a “Source” column: “Shopify login”, “Form”, “Guest” (once
sourcefield 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”| Turn | Condition | What happens |
|---|---|---|
| Turn 1 | sessionId = null, identity available | Session created with visitorName, visitorEmail, visitorPhone, isGuest = false |
| Turn 1 | sessionId = null, guest | Session 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 turn | Shopify customer logged in | Liquid injects identity; widget uses it; localStorage updated |
| Any turn | Same visitor, new tab | localStorage cache used; form skipped |
| Any turn | Different Shopify account | Liquid injects new customer; localStorage overwritten; new session identity |
8. Identity Cache Invalidation
Section titled “8. Identity Cache Invalidation”| Scenario | What happens |
|---|---|
| Shopify customer is logged in | Always overwrites localStorage cache with Shopify data (most authoritative) |
| Customer logs out of Shopify | window.AppifireChat.customer is null; localStorage cache used until expiry or manual clear |
| Different customer logs in on same device | Shopify injects new customer → overwrites localStorage → new identity from next session onward |
| Visitor clears browser storage | Form shown again on next chat open |
| Admin disables form | All visitors treated as guest; localStorage still written with isGuest: true so form not shown repeatedly |
9. Testing Checklist
Section titled “9. Testing Checklist”Shopify customer auto-detection
Section titled “Shopify customer auto-detection”- 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_nameandvisitor_email. -
localStoragekeyapfc_identity_vcontainssource: "shopify". - Log out of Shopify →
window.AppifireChat.customeris null. - Reopen chat → localStorage cache used → form still not shown.
localStorage cache
Section titled “localStorage cache”- Fill the form on one tab → open new tab → chat opens directly, no form shown.
- Clear browser localStorage → form shown again.
Pre-chat form
Section titled “Pre-chat form”- 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 whenfalse. - After submit: messages area appears, welcome message shown.
- After submit: session in DB has correct fields.
- Guest: session has
is_guest = true, identity fieldsnull.
Admin settings
Section titled “Admin settings”- 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
preChatFormEnabledandguestChatAllowed.
AI personalization
Section titled “AI personalization”- 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.
Chat logs
Section titled “Chat logs”- Identified sessions show name + email.
- Guest sessions show “Guest” badge.
10. Summary of All Changes
Section titled “10. Summary of All Changes”| File | Scope |
|---|---|
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.js | Largest: +~150 lines (form HTML/CSS, identity resolution, localStorage, sendMessage patch) |