feat & fixes: Gmail appearence feature & UI fixes

This commit is contained in:
talatops 2026-04-12 13:57:59 +05:00
parent 61b4db37c0
commit f3cedae713
15 changed files with 1749 additions and 193 deletions

View File

@ -0,0 +1,303 @@
# AI modules, questions, and sample answers (shoe store)
This document is aligned with [`docs/req-AI.md`](req-AI.md): **module codes**, **pillars** (K / T / X / catalog metadata), and **`shared_field_id`** labels. Example answers describe a **fictional** store focused on **shoes for both men and women** — replace with your real policies, URLs, and numbers.
**Legend**
| Code | Meaning |
|------|---------|
| **K** | Knowledge (FAQ / KB) |
| **T** | Transactional L1 (verified identity + DB reads) |
| **X** | Ticket-led (human resolves) |
| **Cat** | Catalog metadata (DB-backed category metadetails; **toggle only**, no per-module questionnaire) |
---
## 1. Module catalog (what each module is for)
| Module code | Customer-facing use | Pillar | Vendor “questions” source |
|-------------|----------------------|--------|---------------------------|
| `ALL` | Global store context | — | §6 global questions (`sf_*`) |
| `FAQ` | General policies, how you work | **K** | Shared fields: shipping, returns, payments, contact, policy links, etc. |
| `CATEGORY_METADETAILS` | Category-level facts from your sync | **Cat** | **No questionnaire** — store toggle on/off only |
| `PRODUCT_FIT` | Fit, sizing, materials | **K** | `sf_sizing_fit_guidance`, `sf_compatibility_notes`, `sf_materials_care_limitations`, `sf_human_escalation_when` |
| `STORE_LOCAL` | Hours, pickup, local | **K** | `sf_physical_locations_hours`, `sf_pickup_rules`, `sf_instore_vs_online_inventory`, `sf_how_to_start_return` |
| `FEEDBACK` | Surveys / thanks | **K** | `sf_feedback_csat_process`, tone (`sf_tone_and_words_to_avoid`) |
| `HUMAN_ESCALATION` | “I want a person” | **K** | `sf_human_escalation_when`, `sf_support_*`, `sf_first_message_checklist` |
| `ORDER_STATUS` | Where is my order | **T** | `sf_processing_time_before_ship`, delays, regions, `sf_order_id_help`, checklist |
| `ORDER_SUMMARY` | Receipt recap | **T** | `sf_order_summary_tax_invoice_note`, checklist, order # help |
| `SHIPPING_TRACKING` | Carrier, tracking, ETA | **T** | Carriers, methods, cost, cutoff, regions, customs (`sf_*` shipping set) |
| `ORDER_CANCEL` | Cancel order | **T** | `sf_cancellation_*`, refunds, `sf_order_change_not_possible_next_step` |
| `REFUND_STATUS` | Refund / payout status | **T** | `sf_refund_*` |
| `INVENTORY_STOCK` | In stock? | **T** | `sf_back_in_stock_policy`, `sf_preorder_policy`, `sf_discontinued_product_policy`, `sf_instore_vs_online_inventory` |
| `NOT_RECEIVED_MARKED_DELIVERED` | Tracking says delivered, I don’t have it | **X** | `sf_delivered_not_received_policy`, `sf_carriers_and_tracking_policy` |
| `SHIPMENT_STUCK_OR_DELAYED` | Shipment stuck / very late | **X** | `sf_stuck_shipment_customer_steps`, `sf_major_delay_communication`, carriers |
| `PAYMENT_PROBLEM` | Double charge, promo failed, etc. | **X** | `sf_payment_methods_accepted`, `sf_failed_payment_guidance`, `sf_double_charge_intake`, promo, tax, price adjust |
| `ORDER_CHANGE` | Change address / items / qty | **X** | `sf_order_change_*`, `sf_cancellation_*` |
| `RETURN_EXCHANGE` | Start return or exchange | **X** | `sf_return_*`, `sf_exchange_policy`, `sf_how_to_start_return`, `sf_refund_*` |
| `WARRANTY` | Warranty claim | **X** | `sf_warranty_*`, escalation |
| `WRONG_ITEM` | Wrong / damaged / missing item | **X** | `sf_wrong_item_*`, return shipping, policy links |
| `COMPLAINT` | Strong dissatisfaction | **X** | `sf_complaint_*`, remedies, `sf_support_channels` |
---
## 2. `ALL` — Global questions (G1–G12) + sample answers (shoe store)
| # | Question | `shared_field_id` | Sample answer (men’s & women’s shoes) |
|---|----------|-------------------|----------------------------------------|
| G1 | Brand / store name as customers see it | `sf_brand_display_name` | **Stride & Sole** |
| G2 | One or two sentences: what you sell and for whom | `sf_store_one_liner` | We design and sell **men’s and women’s footwear** — from everyday sneakers and casual shoes to dress and seasonal styles — for customers who care about **fit, comfort, and lasting quality**. |
| G3 | Languages you support in writing | `sf_official_languages` | **English** (email and chat). We can use translation tools for other languages when needed, but official policies and contracts are in English. |
| G4 | Support channels and hours | `sf_support_channels` | **Email:** support@strideandsole.example · **Chat:** storefront widget on our site · **Hours:** Mon–Fri 9:00–18:00 ET; we reply to email within one business day outside chat hours. |
| G5 | Typical first response time | `sf_support_first_response_sla` | **Chat/widget:** usually within a few minutes during business hours. **Email:** within **24 hours** on business days. |
| G6 | Tone and words to avoid | `sf_tone_and_words_to_avoid` | Tone: **friendly, clear, never pushy**. Avoid guaranteeing exact delivery dates, medical claims (e.g. “cures foot pain”), or saying “final sale” unless the product is tagged final sale. |
| G7 | How customers find order # / confirmation | `sf_order_id_help` | Your **order number** appears in your order confirmation email and on your **order status page** (link in that email). It looks like **#1001** or **#SS-1001** depending on our format. |
| G8 | What to include when contacting support | `sf_first_message_checklist` | Please include: **order number**, **email used at checkout**, **shoe model name or SKU** (if about a product), and **photos** for damage/wrong-item issues. |
| G9 | Terms of sale URL | `sf_policy_link_terms` | `https://strideandsole.example/policies/terms-of-service` |
| G10 | Privacy policy URL | `sf_policy_link_privacy` | `https://strideandsole.example/policies/privacy-policy` |
| G11 | Return / refund policy URL (if separate) | `sf_policy_link_returns` | `https://strideandsole.example/policies/refund-policy` |
| G12 | Short paragraph: data in support | `sf_privacy_support_paragraph` | We use your **email, order history, and messages** only to **fulfill orders, answer questions, and improve support**. We don’t sell personal data. Full details are in our privacy policy. |
---
## 3. `FAQ` — Typical topics (FQ1–FQ8 style) + sample answers
*The spec groups FAQ under shipping, returns, refunds **policy**, payments, promo, tax, contact, and policy links. Below are concise shoe-store examples.*
| Topic | Sample answer |
|-------|----------------|
| **Shipping regions** | We ship within **United States** and **Canada**. We do not ship to P.O. boxes for oversized shoe cartons in some carriers — checkout will flag if your address isn’t supported. |
| **Processing time** | In-stock shoes usually ship within **1–2 business days** (see `sf_processing_time_before_ship` in Order status). |
| **Returns policy (policy, not status)** | Unworn shoes in **original box** with tags may be returned within **30 days** of delivery for a refund or exchange; **final sale** and worn items are excluded — see our return policy page. |
| **Refund policy (timeline, not order status)** | Approved refunds are issued to the **original payment method** within **5–10 business days** after we receive the return at our warehouse. |
| **Payments** | We accept major **credit/debit cards**, **Shop Pay**, **PayPal**, and **Apple Pay** where enabled. |
| **Promotions** | One promo code per order unless stated; some **exclusions** apply on limited releases and collaboration shoes. |
| **Tax** | Taxes are **estimated at checkout** and finalized when the order ships, based on shipping address. |
| **Contact** | Prefer **order # + email** for fastest help; use the same email you used to purchase. |
---
## 4. Per-module questions and sample answers
### 4.1 `ORDER_STATUS` (OS1–OS5)
| # | Question | `shared_field_id` | Sample answer |
|---|----------|-------------------|----------------|
| OS1 | Normal processing before shipment? | `sf_processing_time_before_ship` | Most **in-stock** men’s and women’s shoes ship within **1–2 business days**. During launches or sales, handling may extend to **3–5 business days** — we’ll email you if there’s a delay. |
| OS2 | Proactive updates? | *(optional)* | We email **tracking** when your order ships. For delays, we send an update with a **revised estimate**. |
| OS3 | If delayed before shipment | `sf_major_delay_communication` | If your **size or color** is oversold, we’ll email within **48 hours** with options: **wait**, **swap**, or **cancel** for a full refund. |
| OS4 | Ship to / not ship to | `sf_shipping_regions_served`, `sf_shipping_regions_excluded` | **We ship:** US (48 states), DC, Canada. **We don’t ship:** freight-forwarding addresses we can’t verify, some **remote territories** — checkout will block unsupported zones. |
| OS5 | Order # help + checklist | `sf_order_id_help`, `sf_first_message_checklist` | *(Same as G7/G8 in §2.)** |
---
### 4.2 `INVENTORY_STOCK` (IV1–IV5 topics)
| Topic | `shared_field_id` | Sample answer |
|-------|-------------------|----------------|
| Back in stock | `sf_back_in_stock_policy` | Popular **sizes (e.g. women’s 8, men’s 10)** can sell out — use **“Notify me”** on the product page; we’ll email when we restock. |
| Pre-orders | `sf_preorder_policy` | **Limited drops** may be pre-order: you’re charged at checkout; we ship by the **stated date** or earlier if ready. |
| Discontinued | `sf_discontinued_product_policy` | When a **style is discontinued**, we remove or mark it **sold out** and may suggest a **similar model** in email if you ask support. |
| In-store vs online | `sf_instore_vs_online_inventory` | **Online stock** is separate from our **flagship store** inventory — an item may be online-only or store-only; ask us with **SKU** to check. |
---
### 4.3 `ORDER_CANCEL` (CX1–CX5 topics)
| Topic | `shared_field_id` | Sample answer |
|-------|-------------------|----------------|
| Cancellation window | `sf_cancellation_window` | Cancel **before shipment** for a full refund. Once **tracking** exists, cancellation may not be possible — start a **return** instead. |
| How to request | `sf_cancellation_how_to_request` | Email **support@…** with **order #** or use **Order status** → Cancel if the button is available. |
| If cancel not possible | `sf_order_change_not_possible_next_step` | If the package **already shipped**, please **refuse delivery** or use our **return portal** after delivery. |
| Pre-order / special | `sf_cancellation_preorder_digital` | Pre-order **collab shoes** may have stricter rules — see the product page; some are **non-cancellable** after 24 hours. |
| Refund timing | `sf_refund_timeline_after_approval` | *(Align with FAQ)* **5–10 business days** after approval to original payment method. |
---
### 4.4 `HUMAN_ESCALATION` (HE1–HE4 topics)
| Topic | `shared_field_id` | Sample answer |
|-------|-------------------|----------------|
| When to escalate | `sf_human_escalation_when` | We bring in a human for **billing disputes**, **damaged/lost** shipments, **exchange exceptions**, or whenever **automation can’t verify** your order. |
| Channels / SLA | `sf_support_*` | Email and chat per **§2 G4/G5**. |
| Checklist | `sf_first_message_checklist` | *(Same as G8.)** |
---
### 4.5 `COMPLAINT` (CP1–CP6 topics)
| Topic | `shared_field_id` | Sample answer |
|-------|-------------------|----------------|
| Acknowledgement | `sf_complaint_acknowledgement_sla` | We acknowledge serious complaints within **24 business hours** and aim for a full reply within **3 business days**. |
| Supervisor | `sf_complaint_supervisor_escalation` | Repeated service failures or **order errors over $200** are escalated to a **lead**. |
| Remedies | `sf_wrong_item_remedies`, `sf_refund_methods` | We may offer **refund, replacement, or store credit** per policy and case. |
| Abuse | `sf_abusive_contact_policy` | We protect our team: **harassment or threats** may result in **restricted support channels** per our terms. |
---
### 4.6 `ORDER_SUMMARY` (SM1–SM4 topics)
| Topic | `shared_field_id` | Sample answer |
|-------|-------------------|----------------|
| Tax / invoice note | `sf_order_summary_tax_invoice_note` | Order emails show **subtotal, shipping, tax estimate**. A **formal VAT invoice** for EU/CA B2B may be requested by email with **order #** and **tax ID**. |
| Checklist / order # | `sf_first_message_checklist`, `sf_order_id_help` | *(Same as §2.)** |
---
### 4.7 `REFUND_STATUS` (RF1–RF6 topics)
| Topic | `shared_field_id` | Sample answer |
|-------|-------------------|----------------|
| Methods | `sf_refund_methods` | **Original payment** or **store credit** if you chose credit at return submission. |
| Timeline | `sf_refund_timeline_after_approval` | **5–10 business days** after the return is **received and inspected** at our warehouse. |
| Partial refunds | `sf_partial_refund_rules` | Partial refunds may apply if **shoes show wear** or **accessories are missing** from the box. |
| Store credit | `sf_store_credit_rules` | Store credit **expires in 12 months** and can combine with one promo unless stated otherwise. |
---
### 4.8 `SHIPPING_TRACKING` (ST1–ST6 topics)
| Topic | Typical `shared_field_id` | Sample answer |
|-------|---------------------------|----------------|
| Carriers / tracking | `sf_carriers_and_tracking_policy` | We use **UPS**, **FedEx**, and **USPS**; tracking is emailed when the **label is created**. |
| Methods / transit | `sf_shipping_methods_and_transit` | **Standard:** 3–7 business days; **Express:** 2–3 business days (US). |
| Cost | `sf_shipping_cost_model` | **Free standard shipping** over **$75** US; otherwise rates shown at checkout. |
| Cutoff | `sf_dispatch_cutoff` | Orders before **12:00 ET** ship same business day when possible. |
| Customs | `sf_customs_duties_policy` | **Canada/EU:** duties and taxes may be **COD or prepaid** per carrier — shown at checkout. |
---
### 4.9 `WRONG_ITEM` (WI1–WI6 topics)
| Topic | `shared_field_id` | Sample answer |
|-------|-------------------|----------------|
| Report deadline | `sf_wrong_item_report_deadline` | Report **wrong size sent**, **color errors**, or **damage** within **14 days** of delivery. |
| Evidence | `sf_wrong_item_evidence` | Send **photos of the shoe box label**, **both shoes**, and **packing slip**. |
| Remedies | `sf_wrong_item_remedies` | We’ll offer **free return label + exchange** or **full refund** if we shipped the wrong SKU. |
---
### 4.10 `RETURN_EXCHANGE` (RE1–RE6 topics)
| Topic | `shared_field_id` | Sample answer |
|-------|-------------------|----------------|
| Window | `sf_return_window` | **30 days** from delivery; shoes must be **unworn** with **tags and original box**. |
| Condition | `sf_return_condition_requirements` | **No visible wear** on soles; **laces and insoles** included; **no perfume/smoke** odor for health reasons. |
| Non-returnable | `sf_non_returnable_categories` | **Socks**, **insoles** if opened, **final sale** collaborations, **clearance** marked as final. |
| Return shipping | `sf_return_shipping_payer` | **Customer pays** return shipping unless we **erred** (wrong item) — then we provide a **prepaid label**. |
| Exchange | `sf_exchange_policy` | **Size exchanges** are free on first exchange for the **same model** if in stock; otherwise refund. |
---
### 4.11 `NOT_RECEIVED_MARKED_DELIVERED` (NR1–NR2)
| Topic | `shared_field_id` | Sample answer |
|-------|-------------------|----------------|
| Steps | `sf_delivered_not_received_policy` | Check **neighbors / front desk**, **carrier proof of delivery photo**, wait **24 hours**, then email us with **order #**. |
| Carrier | `sf_carriers_and_tracking_policy` | We open a **carrier trace**; if confirmed lost, we **reship or refund** after investigation (often **5–10 business days**). |
---
### 4.12 `SHIPMENT_STUCK_OR_DELAYED` (SD1–SD3)
| Topic | `shared_field_id` | Sample answer |
|-------|-------------------|----------------|
| Customer steps | `sf_stuck_shipment_customer_steps` | If tracking **hasn’t moved 5+ business days**, email us with **order #** — we file a **carrier inquiry**. |
| Major delays | `sf_major_delay_communication` | Weather or peak season: we post notices on the **site banner** and send **delay emails** when we know. |
| Carriers | `sf_carriers_and_tracking_policy` | *(Same as shipping.)** |
---
### 4.13 `PAYMENT_PROBLEM` (PP1–PP6 topics)
| Topic | `shared_field_id` | Sample answer |
|-------|-------------------|----------------|
| Payment methods | `sf_payment_methods_accepted` | Cards, Shop Pay, PayPal, Apple Pay — region-dependent. |
| Failed payment | `sf_failed_payment_guidance` | Retry with another card; ensure **billing matches bank**; contact bank if **3D Secure** fails. |
| Double charge | `sf_double_charge_intake` | Send **order #**, **two charge amounts**, and **bank screenshots**; we resolve within **5 business days**. |
| Promo / tax / price | `sf_promo_discount_rules`, `sf_tax_display_policy`, `sf_price_adjustment_policy` | One code per order; tax per address; **price adjustments** within **7 days** if we run a public sale on the same SKU. |
---
### 4.14 `ORDER_CHANGE` (OC1–OC4)
| Topic | `shared_field_id` | Sample answer |
|-------|-------------------|----------------|
| Until when | `sf_order_change_allowed_until` | **Address changes** only before **warehouse pick** — usually **same day** if before **12:00 ET**. |
| How to request | `sf_order_change_how_to_request` | Email **support@…** with **order #** and **new address**; we can’t guarantee after ship. |
| If impossible | `sf_order_change_not_possible_next_step` | After ship, **redirect or hold** with carrier when possible — fees may apply. |
| Cancel alternative | `sf_cancellation_*` | *(See cancel section.)** |
---
### 4.15 `WARRANTY` (WA1–WA5)
| Topic | `shared_field_id` | Sample answer |
|-------|-------------------|----------------|
| Scope | `sf_warranty_duration_scope` | **Manufacturing defects** (e.g. **sole separation**, **bad stitching**) for **6 months** from delivery. |
| Void | `sf_warranty_voiders` | **Normal wear**, **water damage** beyond materials rating, **unauthorized repair**, **cut or altered** shoes. |
| Process | `sf_warranty_claim_process` | Email **photos + order #**; we may ask you to **ship shoes for inspection**; **repair or replace** at our discretion. |
---
### 4.16 `PRODUCT_FIT` (PF1–PF4)
| Topic | `shared_field_id` | Sample answer |
|-------|-------------------|----------------|
| Sizing | `sf_sizing_fit_guidance` | **Men’s and women’s** sizes use **US Brannock-style** charts; **wide** widths available on select models — see each **PDP size chart**. Half sizes: **order up** if between sizes for closed-toe dress shoes. |
| Compatibility | `sf_compatibility_notes` | **Orthotic-friendly** models list **removable insole** in specs; not all dress shoes fit thick orthotics. |
| Materials / care | `sf_materials_care_limitations` | **Leather:** brush + conditioner; **suede:** spray + soft brush; **knit:** cold hand wash only for some — follow **care card in box**. |
| Human edge cases | `sf_human_escalation_when` | **Custom orthotic fit** or **medical foot conditions** — we don’t give medical advice; we suggest a **specialist** and our **widest lasts**. |
---
### 4.17 `STORE_LOCAL` (SL1–SL4)
| Topic | `shared_field_id` | Sample answer |
|-------|-------------------|----------------|
| Locations / hours | `sf_physical_locations_hours` | **Flagship:** 123 Example St, NY — **Mon–Sat 10–19**, Sun 12–18 (example). |
| Pickup | `sf_pickup_rules` | **Buy online, pick up in store** — bring **ID + order email**; hold **7 days**. |
| Inventory | `sf_instore_vs_online_inventory` | **Store try-on** stock may differ from **warehouse** — ask staff to **transfer** if needed. |
| Local returns | `sf_how_to_start_return` | **In-store returns** for online orders with **QR code** from return portal. |
---
### 4.18 `FEEDBACK` (FB1–FB3)
| Topic | `shared_field_id` | Sample answer |
|-------|-------------------|----------------|
| CSAT | `sf_feedback_csat_process` | After **delivery**, we may send a **short survey** (email); optional **review link** for verified buyers. |
| Tone | `sf_tone_and_words_to_avoid` | *(See G6.)** |
---
### 4.19 `CATEGORY_METADETAILS`
- **No written questionnaire** in the spec.
- **Configuration:** one store toggle — when **enabled**, the agent reads **category metafields** you sync (e.g. **fit notes for “Running” vs “Heels”**, **material blocks** per category).
- **Sample note for your shoe store:** In Shopify (or your source), maintain **category-level** text such as: *“Women’s heels: true-to-size for narrow feet; size up for wide.”* / *“Men’s running: half-size up for long runs.”* The AI uses that **only when the toggle is on** and the product’s category is resolved.
---
## 5. Structured “store voice” (v3 template) — optional mirror
If you use **JUNO structured KB** voice fields (`knowledgeTemplate` v3), these pair with the generic keys in code:
| Field key | Label (from app) | Sample answer (shoe store) |
|-----------|------------------|----------------------------|
| `aiToneHowWeSound` | How should the AI sound? | Warm, concise, **obsessed with fit** — we celebrate **men’s and women’s** styles equally and never talk down about budget vs premium lines. |
| `communicationFormality` | Formality | **Professional but friendly** — “we” and “you,” short paragraphs. |
| `phrasesAndPromisesToAvoid` | Words to avoid | No **guaranteed delivery dates**; no claiming **medical** benefits; don’t promise **restock dates** we haven’t confirmed. |
| `whenWeDoNotKnow` | When unsure | Say we’re **not sure**, offer to **email support** with **order #**, or link to **size chart** and **care guide**. |
---
## 6. Disclaimer
- **Replace** placeholders (brand, URLs, hours, days, carriers) with your **real** data before publishing or embedding.
- **Transactional** answers (order status, tracking, stock) must match **your database / Shopify** at runtime — this file is **policy and copy**, not live data.
- Canonical module list and IDs: **[`docs/req-AI.md`](req-AI.md)** §3, §5–§7.
---
*Generated for: **men’s and women’s shoe** positioning — adjust for your exact catalog and regions.*

View File

@ -14,6 +14,8 @@
"test:gmail-auto-reply": "set -e; docker compose -f tests/gmail-auto-reply/docker-compose.test.yml up -d; export $(cat tests/gmail-auto-reply/env.test | grep -v '^#' | xargs); docker compose -f tests/gmail-auto-reply/docker-compose.test.yml exec -T db sh -lc \"psql -U postgres -d dropshipping -c 'CREATE EXTENSION IF NOT EXISTS vector;'\"; npx prisma db push --accept-data-loss; npx tsx tests/gmail-auto-reply/seed.ts; vitest run tests/gmail-auto-reply/run.test.ts; docker compose -f tests/gmail-auto-reply/docker-compose.test.yml down -v", "test:gmail-auto-reply": "set -e; docker compose -f tests/gmail-auto-reply/docker-compose.test.yml up -d; export $(cat tests/gmail-auto-reply/env.test | grep -v '^#' | xargs); docker compose -f tests/gmail-auto-reply/docker-compose.test.yml exec -T db sh -lc \"psql -U postgres -d dropshipping -c 'CREATE EXTENSION IF NOT EXISTS vector;'\"; npx prisma db push --accept-data-loss; npx tsx tests/gmail-auto-reply/seed.ts; vitest run tests/gmail-auto-reply/run.test.ts; docker compose -f tests/gmail-auto-reply/docker-compose.test.yml down -v",
"re-embed-knowledge-base": "tsx scripts/re-embed-knowledge-base.ts", "re-embed-knowledge-base": "tsx scripts/re-embed-knowledge-base.ts",
"db:reset": "prisma migrate reset", "db:reset": "prisma migrate reset",
"db:apply-gmail-reply-theme-column": "prisma db execute --file prisma/migrations/20260412160000_store_gmail_reply_theme/migration.sql",
"db:baseline-migrations": "bash scripts/prisma-baseline-record-all-applied.sh",
"db:studio": "prisma studio", "db:studio": "prisma studio",
"db:fix-migration": "tsx scripts/fix-failed-migration.ts", "db:fix-migration": "tsx scripts/fix-failed-migration.ts",
"db:role-audit": "tsx scripts/audit-role-consistency.ts" "db:role-audit": "tsx scripts/audit-role-consistency.ts"

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Store" ADD COLUMN IF NOT EXISTS "gmailReplyTheme" JSONB;

View File

@ -129,6 +129,9 @@ model Store {
/** Vendor Type-1 answers keyed by shared_field_id (sf_*); one value per key store-wide (§4.2). */ /** Vendor Type-1 answers keyed by shared_field_id (sf_*); one value per key store-wide (§4.2). */
sharedFieldAnswers Json? sharedFieldAnswers Json?
/** Optional Gmail auto-reply HTML theming (colors, font, logo URL). Merged with defaults server-side. */
gmailReplyTheme Json?
// Relations // Relations
connections Connection[] connections Connection[]
cachedProducts CachedProduct[] cachedProducts CachedProduct[]

View File

@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Use ONCE on a database that already matches prisma/schema.prisma but has no (or incomplete)
# _prisma_migrations history — e.g. after P3005 on `prisma migrate deploy`.
# This does NOT run migration SQL; it only records each folder under prisma/migrations as applied.
# If your DB is missing tables/columns, fix schema first (e.g. prisma db execute or manual SQL).
set -euo pipefail
cd "$(dirname "$0")/.."
echo "WARNING: This marks all migrations as applied without running them."
echo "Only continue if your database schema already matches the Prisma schema."
if [[ "${SKIP_CONFIRM:-}" != "YES" ]]; then
read -r -p "Type YES to continue: " ok
if [[ ! "$ok" == "YES" ]]; then
echo "Aborted."
exit 1
fi
fi
for dir in prisma/migrations/*/; do
name="$(basename "$dir")"
if [[ "$name" == "migration_lock.toml" ]] || [[ ! -d "$dir" ]]; then
continue
fi
echo "Recording as applied: $name"
npx prisma migrate resolve --applied "$name"
done
echo "Done. Run: npx prisma migrate status"

View File

@ -17,6 +17,11 @@ import {
removeThemeLiquidInjection, removeThemeLiquidInjection,
upsertThemeLiquidInjection upsertThemeLiquidInjection
} from "@/lib/shopify/theme" } from "@/lib/shopify/theme"
import {
gmailReplyThemeBodySchema,
parseGmailReplyThemeJson,
resolvedThemeFromBody,
} from "@/lib/gmail/emailReplyTheme"
/** /**
* DELETE /api/stores/[storeId] * DELETE /api/stores/[storeId]
@ -554,6 +559,7 @@ export async function GET(
aiModules: unknown aiModules: unknown
modulePolicies?: unknown modulePolicies?: unknown
sharedFieldAnswers?: unknown sharedFieldAnswers?: unknown
gmailReplyTheme?: unknown
} }
let store: StoreGetRow | null = null let store: StoreGetRow | null = null
@ -567,6 +573,7 @@ export async function GET(
aiModules: true, aiModules: true,
modulePolicies: true, modulePolicies: true,
sharedFieldAnswers: true, sharedFieldAnswers: true,
gmailReplyTheme: true,
}, },
}) })
} catch (firstErr) { } catch (firstErr) {
@ -581,6 +588,7 @@ export async function GET(
businessName: true, businessName: true,
categoryMetadetailsEnabled: true, categoryMetadetailsEnabled: true,
aiModules: true, aiModules: true,
gmailReplyTheme: true,
}, },
}) })
} }
@ -596,6 +604,12 @@ export async function GET(
aiModules: store.aiModules, aiModules: store.aiModules,
modulePolicies: parseModulePoliciesJson(store.modulePolicies ?? null), modulePolicies: parseModulePoliciesJson(store.modulePolicies ?? null),
sharedFieldAnswers: parseSharedFieldAnswersJson(store.sharedFieldAnswers ?? null), sharedFieldAnswers: parseSharedFieldAnswersJson(store.sharedFieldAnswers ?? null),
gmailReplyTheme:
store.gmailReplyTheme === undefined
? undefined
: store.gmailReplyTheme === null
? null
: parseGmailReplyThemeJson(store.gmailReplyTheme),
}) })
} catch (error) { } catch (error) {
console.error("Error reading store settings:", error) console.error("Error reading store settings:", error)
@ -697,6 +711,25 @@ export async function PATCH(
reembedStructured = true reembedStructured = true
} }
if (data.gmailReplyTheme !== undefined) {
if (data.gmailReplyTheme === null) {
updatePayload.gmailReplyTheme = Prisma.JsonNull
} else {
const parsed = gmailReplyThemeBodySchema.safeParse(data.gmailReplyTheme)
if (!parsed.success) {
return NextResponse.json(
{ message: "Invalid gmailReplyTheme", issues: parsed.error.flatten() },
{ status: 400 },
)
}
const normalized = resolvedThemeFromBody(parsed.data)
// Plain JSON object for Prisma Json (avoids odd runtime serialization edge cases).
updatePayload.gmailReplyTheme = JSON.parse(
JSON.stringify(normalized),
) as Prisma.InputJsonValue
}
}
const updatedStore = await prisma.store.update({ const updatedStore = await prisma.store.update({
where: { id: storeId }, where: { id: storeId },
data: updatePayload, data: updatePayload,
@ -744,6 +777,10 @@ export async function PATCH(
aiModules: updatedStore.aiModules, aiModules: updatedStore.aiModules,
modulePolicies: parseModulePoliciesJson(updatedStore.modulePolicies), modulePolicies: parseModulePoliciesJson(updatedStore.modulePolicies),
sharedFieldAnswers: parseSharedFieldAnswersJson(updatedStore.sharedFieldAnswers), sharedFieldAnswers: parseSharedFieldAnswersJson(updatedStore.sharedFieldAnswers),
gmailReplyTheme:
updatedStore.gmailReplyTheme == null
? null
: parseGmailReplyThemeJson(updatedStore.gmailReplyTheme),
}) })
} catch (error) { } catch (error) {
console.error("Error updating store settings:", error) console.error("Error updating store settings:", error)

View File

@ -0,0 +1,65 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth.vendor"
import { prisma } from "@/lib/prisma"
import fs from "fs/promises"
import path from "path"
import { randomUUID } from "crypto"
const LOCAL_DIR = process.env.GMAIL_REPLY_LOGOS_DIR || "public/uploads/gmail-reply-logos"
export async function POST(req: Request) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
const formData = await req.formData()
const file = formData.get("file")
const storeIdRaw = formData.get("storeId")
if (!(file instanceof File)) {
return NextResponse.json({ message: "Missing file" }, { status: 400 })
}
if (typeof storeIdRaw !== "string" || !storeIdRaw.trim()) {
return NextResponse.json({ message: "Missing storeId" }, { status: 400 })
}
const storeId = storeIdRaw.trim()
const store = await prisma.store.findFirst({
where: { id: storeId, userId: session.user.id },
select: { id: true },
})
if (!store) {
return NextResponse.json({ message: "Store not found" }, { status: 404 })
}
if (!file.type.startsWith("image/")) {
return NextResponse.json({ message: "Only image files are allowed" }, { status: 400 })
}
const maxBytes = 2 * 1024 * 1024
if (file.size > maxBytes) {
return NextResponse.json({ message: "Image too large (max 2MB)" }, { status: 400 })
}
const arrayBuffer = await file.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
const originalName = file.name || "logo"
const extFromName = originalName.includes(".") ? originalName.split(".").pop() : undefined
const ext = extFromName || "png"
const baseDir = path.resolve(LOCAL_DIR)
const storeDir = path.join(baseDir, storeId)
await fs.mkdir(storeDir, { recursive: true })
const fileNameOnDisk = `${randomUUID()}.${ext}`
const storagePath = path.join(storeDir, fileNameOnDisk)
await fs.writeFile(storagePath, buffer)
const publicUrl = `/uploads/gmail-reply-logos/${storeId}/${fileNameOnDisk}`
return NextResponse.json({ url: publicUrl })
} catch (error) {
console.error("[GMAIL_REPLY_LOGO_UPLOAD]", error)
return NextResponse.json({ message: "Failed to upload logo image" }, { status: 500 })
}
}

View File

@ -214,7 +214,9 @@ export default function CustomersPage() {
<thead> <thead>
<tr className="bg-white/5 border-b border-white/10"> <tr className="bg-white/5 border-b border-white/10">
<th className="px-4 md:px-8 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest min-w-[200px]">Customer</th> <th className="px-4 md:px-8 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest min-w-[200px]">Customer</th>
<th className="px-3 md:px-6 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest whitespace-nowrap">Orders</th> <th className="px-3 md:px-6 py-3 md:py-4 text-left text-[10px] font-bold text-slate-400 uppercase tracking-widest whitespace-nowrap">
Orders
</th>
<th className="px-3 md:px-6 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest whitespace-nowrap">Spent</th> <th className="px-3 md:px-6 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest whitespace-nowrap">Spent</th>
<th className="px-3 md:px-6 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest whitespace-nowrap hidden md:table-cell">Location</th> <th className="px-3 md:px-6 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest whitespace-nowrap hidden md:table-cell">Location</th>
<th className="px-3 md:px-6 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest whitespace-nowrap hidden lg:table-cell">Member Since</th> <th className="px-3 md:px-6 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest whitespace-nowrap hidden lg:table-cell">Member Since</th>
@ -242,8 +244,8 @@ export default function CustomersPage() {
</div> </div>
</div> </div>
</td> </td>
<td className="px-3 md:px-6 py-3 md:py-4 text-center"> <td className="px-3 md:px-6 py-3 md:py-4 align-middle text-left">
<div className="px-2 py-1 bg-indigo-500/20 text-indigo-400 rounded-lg inline-flex items-center gap-1.5 font-black text-[10px]"> <div className="inline-flex items-center gap-1.5 rounded-lg bg-indigo-500/20 px-2 py-1 font-black text-[10px] text-indigo-400">
{customer.orders_count} ORDERS {customer.orders_count} ORDERS
</div> </div>
</td> </td>

View File

@ -1,22 +1,189 @@
"use client" "use client"
import { useState, useEffect, Suspense } from "react" import { useState, useEffect, Suspense, useMemo, useCallback } from "react"
import { createPortal } from "react-dom"
import { useSearchParams } from "next/navigation" import { useSearchParams } from "next/navigation"
import toast from "react-hot-toast" import toast from "react-hot-toast"
import { import {
Mail, Mail,
CheckCircle2, CheckCircle2,
Loader2, Loader2,
Settings Settings,
Palette,
X,
Save,
RotateCcw,
Upload,
} from "lucide-react" } from "lucide-react"
import {
buildGmailReplyDemoHtml,
parseGmailReplyThemeJson,
GMAIL_REPLY_FONT_OPTIONS,
wrapGmailHtmlDocument,
type ResolvedGmailReplyTheme,
} from "@/lib/gmail/emailReplyTheme"
import { useTheme } from "@/components/ThemeProvider"
import { cn, publicAssetUrlForUi } from "@/lib/utils"
function ColorField({
label,
value,
onChange,
isLight,
}: {
label: string
value: string
onChange: (v: string) => void
isLight: boolean
}) {
const normalized = /^#[0-9a-fA-F]{6}$/.test(value) ? value : "#000000"
return (
<label className="flex flex-col gap-1.5 text-left">
<span
className={cn(
"text-[11px] font-semibold uppercase tracking-wide",
isLight ? "text-slate-500" : "text-slate-500",
)}
>
{label}
</span>
<div className="flex items-center gap-2">
<input
type="color"
value={normalized}
onChange={(e) => onChange(e.target.value)}
className={cn(
"h-9 w-12 shrink-0 cursor-pointer rounded-lg border p-0.5",
isLight ? "border-slate-200 bg-white" : "border-white/20 bg-slate-900",
)}
aria-label={label}
/>
<input
value={value}
onChange={(e) => onChange(e.target.value)}
className={cn(
"min-w-0 flex-1 rounded-lg border px-2.5 py-1.5 font-mono text-xs",
isLight
? "border-slate-200 bg-white text-slate-900"
: "border-white/15 bg-white/5 text-slate-100",
)}
spellCheck={false}
/>
</div>
</label>
)
}
function GmailConnectContent() { function GmailConnectContent() {
const { theme } = useTheme()
const isLight = theme === "light"
const searchParams = useSearchParams() const searchParams = useSearchParams()
const [isConnected, setIsConnected] = useState(false) const [isConnected, setIsConnected] = useState(false)
const [connecting, setConnecting] = useState(false) const [connecting, setConnecting] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [gmailEmail, setGmailEmail] = useState<string | null>(null) const [gmailEmail, setGmailEmail] = useState<string | null>(null)
const [mounted, setMounted] = useState(false)
const [activeStoreId, setActiveStoreId] = useState<string | null>(null)
const [storeLoading, setStoreLoading] = useState(true)
const [businessName, setBusinessName] = useState("Your store")
const [themeModalOpen, setThemeModalOpen] = useState(false)
const [themeDraft, setThemeDraft] = useState<ResolvedGmailReplyTheme>(() => parseGmailReplyThemeJson(null))
const [themeSaving, setThemeSaving] = useState(false)
const [uploadingGmailLogo, setUploadingGmailLogo] = useState(false)
const previewDoc = useMemo(() => {
const fragment = buildGmailReplyDemoHtml(businessName, themeDraft)
return wrapGmailHtmlDocument(fragment, themeDraft)
}, [businessName, themeDraft])
const loadStoreTheme = useCallback(async (storeId: string, signal?: AbortSignal) => {
const res = await fetch(`/api/stores/${encodeURIComponent(storeId)}`, { signal })
const raw = await res.json().catch(() => ({}))
if (!res.ok) return
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return
const data = raw as Record<string, unknown>
if (typeof data.businessName === "string" && data.businessName.trim()) {
setBusinessName(data.businessName.trim())
}
// Only apply theme when the API included the field. If the key is missing (e.g. older
// GET fallback), do not overwrite local state — avoids a stale GET wiping a just-saved theme.
if (Object.prototype.hasOwnProperty.call(data, "gmailReplyTheme")) {
setThemeDraft(parseGmailReplyThemeJson(data.gmailReplyTheme ?? null))
}
}, [])
useEffect(() => {
setMounted(true)
}, [])
useEffect(() => {
let cancelled = false
;(async () => {
try {
const res = await fetch("/api/stores")
if (!res.ok || cancelled) return
const data = (await res.json()) as { stores?: { id: string; isActive: boolean }[] }
const active = (data.stores || []).find((s) => s.isActive)
if (!cancelled) setActiveStoreId(active?.id ?? null)
} catch {
if (!cancelled) setActiveStoreId(null)
} finally {
if (!cancelled) setStoreLoading(false)
}
})()
return () => {
cancelled = true
}
}, [])
useEffect(() => {
if (!activeStoreId) return
const ac = new AbortController()
;(async () => {
try {
await loadStoreTheme(activeStoreId, ac.signal)
} catch (e) {
if (e instanceof Error && e.name === "AbortError") return
}
})()
return () => ac.abort()
}, [activeStoreId, loadStoreTheme])
/** Reload theme from server when opening the modal so the form always matches persisted data. */
useEffect(() => {
if (!themeModalOpen || !activeStoreId) return
const ac = new AbortController()
;(async () => {
try {
await loadStoreTheme(activeStoreId, ac.signal)
} catch (e) {
if (e instanceof Error && e.name === "AbortError") return
}
})()
return () => ac.abort()
}, [themeModalOpen, activeStoreId, loadStoreTheme])
useEffect(() => {
if (!themeModalOpen) return
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setThemeModalOpen(false)
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [themeModalOpen])
useEffect(() => {
if (themeModalOpen) {
document.body.style.overflow = "hidden"
} else {
document.body.style.overflow = ""
}
return () => {
document.body.style.overflow = ""
}
}, [themeModalOpen])
useEffect(() => { useEffect(() => {
const checkConnection = async () => { const checkConnection = async () => {
try { try {
@ -34,7 +201,6 @@ function GmailConnectContent() {
} }
checkConnection() checkConnection()
// Check URL params for connection status
const connected = searchParams.get("connected") const connected = searchParams.get("connected")
const error = searchParams.get("error") const error = searchParams.get("error")
@ -42,7 +208,6 @@ function GmailConnectContent() {
setIsConnected(true) setIsConnected(true)
setLoading(false) setLoading(false)
toast.success("Gmail connected successfully!") toast.success("Gmail connected successfully!")
// Refresh connection status to get email
checkConnection() checkConnection()
} }
if (error) { if (error) {
@ -55,7 +220,7 @@ function GmailConnectContent() {
callback_failed: "Connection callback failed", callback_failed: "Connection callback failed",
redirect_uri_mismatch: redirectUri redirect_uri_mismatch: redirectUri
? `Redirect URI mismatch. The redirect URI "${redirectUri}" must be added to your Google Cloud Console OAuth client. Go to Google Cloud Console > APIs & Services > Credentials > Your OAuth Client > Authorized redirect URIs and add this exact URI.` ? `Redirect URI mismatch. The redirect URI "${redirectUri}" must be added to your Google Cloud Console OAuth client. Go to Google Cloud Console > APIs & Services > Credentials > Your OAuth Client > Authorized redirect URIs and add this exact URI.`
: "Redirect URI mismatch. Please check your Google Cloud Console OAuth configuration." : "Redirect URI mismatch. Please check your Google Cloud Console OAuth configuration.",
} }
const errorMessage = errorMessages[error] || "Failed to connect Gmail. Please try again." const errorMessage = errorMessages[error] || "Failed to connect Gmail. Please try again."
toast.error(errorMessage, { duration: redirectUri ? 10000 : 5000 }) toast.error(errorMessage, { duration: redirectUri ? 10000 : 5000 })
@ -66,9 +231,8 @@ function GmailConnectContent() {
const handleConnect = async () => { const handleConnect = async () => {
setConnecting(true) setConnecting(true)
try { try {
// Redirect to Gmail OAuth
const res = await fetch("/api/integrations/gmail/connect", { const res = await fetch("/api/integrations/gmail/connect", {
method: "POST" method: "POST",
}) })
const data = await res.json() const data = await res.json()
@ -87,7 +251,7 @@ function GmailConnectContent() {
const handleDisconnect = async () => { const handleDisconnect = async () => {
try { try {
const res = await fetch("/api/integrations/gmail/disconnect", { const res = await fetch("/api/integrations/gmail/disconnect", {
method: "POST" method: "POST",
}) })
if (res.ok) { if (res.ok) {
setIsConnected(false) setIsConnected(false)
@ -103,45 +267,567 @@ function GmailConnectContent() {
} }
} }
async function saveGmailTheme() {
if (!activeStoreId) {
toast.error("No active store")
return
}
setThemeSaving(true)
try {
const res = await fetch(`/api/stores/${encodeURIComponent(activeStoreId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ gmailReplyTheme: themeDraft }),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
throw new Error(
typeof (data as { message?: string }).message === "string"
? (data as { message: string }).message
: "Save failed",
)
}
const patch = data as { gmailReplyTheme?: ResolvedGmailReplyTheme | null }
if (patch.gmailReplyTheme !== undefined) {
setThemeDraft(parseGmailReplyThemeJson(patch.gmailReplyTheme ?? null))
}
toast.success("Email appearance saved")
setThemeModalOpen(false)
} catch (e) {
toast.error(e instanceof Error ? e.message : "Save failed")
} finally {
setThemeSaving(false)
}
}
async function handleGmailLogoUpload(file: File | null) {
if (!file || !activeStoreId) return
setUploadingGmailLogo(true)
try {
const fd = new FormData()
fd.append("file", file)
fd.append("storeId", activeStoreId)
const res = await fetch("/api/uploads/gmail-reply-logo", {
method: "POST",
body: fd,
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
throw new Error(typeof data.message === "string" ? data.message : "Upload failed")
}
if (typeof data.url !== "string") {
throw new Error("Invalid upload response")
}
setThemeDraft((d) => ({ ...d, logoUrl: data.url }))
toast.success("Logo uploaded")
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed")
} finally {
setUploadingGmailLogo(false)
}
}
async function clearGmailTheme() {
if (!activeStoreId) return
setThemeSaving(true)
try {
const res = await fetch(`/api/stores/${encodeURIComponent(activeStoreId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ gmailReplyTheme: null }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(
typeof (data as { message?: string }).message === "string"
? (data as { message: string }).message
: "Reset failed",
)
}
setThemeDraft(parseGmailReplyThemeJson(null))
toast.success("Restored default appearance")
} catch (e) {
toast.error(e instanceof Error ? e.message : "Reset failed")
} finally {
setThemeSaving(false)
}
}
const themeModal =
mounted &&
themeModalOpen &&
createPortal(
<div
className="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6"
role="dialog"
aria-modal="true"
aria-labelledby="gmail-theme-title"
>
<button
type="button"
className={cn(
"absolute inset-0 backdrop-blur-sm",
isLight ? "bg-slate-900/35" : "bg-slate-950/70",
)}
aria-label="Close"
onClick={() => setThemeModalOpen(false)}
/>
<div
className={cn(
"relative z-[101] flex max-h-[min(92vh,880px)] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border shadow-2xl",
isLight
? "border-slate-200 bg-white shadow-slate-900/10"
: "border-white/10 bg-slate-900 shadow-black/40",
)}
>
<div
className={cn(
"flex shrink-0 items-center justify-between gap-3 border-b px-5 py-4",
isLight ? "border-slate-200" : "border-white/10",
)}
>
<div className="min-w-0">
<h2
id="gmail-theme-title"
className={cn("text-lg font-bold", isLight ? "text-slate-900" : "text-white")}
>
Email appearance
</h2>
<p className={cn("mt-0.5 text-xs", isLight ? "text-slate-600" : "text-slate-400")}>
Colors, font, and logo apply to automated Gmail replies. Preview updates as you edit.
</p>
</div>
<button
type="button"
onClick={() => setThemeModalOpen(false)}
className={cn(
"rounded-xl p-2 transition",
isLight
? "text-slate-500 hover:bg-slate-100 hover:text-slate-900"
: "text-slate-400 hover:bg-white/10 hover:text-white",
)}
aria-label="Close"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="grid min-h-0 flex-1 grid-cols-1 gap-0 lg:grid-cols-[minmax(280px,380px)_1fr]">
<div
className={cn(
"max-h-[min(92vh,880px)] overflow-y-auto border-b p-5 lg:border-b-0 lg:border-r",
isLight ? "border-slate-200 lg:border-slate-200" : "border-white/10 lg:border-white/10",
)}
>
<div className="space-y-4">
<div>
<label
className={cn(
"text-[11px] font-semibold uppercase tracking-wide",
isLight ? "text-slate-500" : "text-slate-500",
)}
>
Logo image (optional)
</label>
<div className="mt-1.5 flex flex-wrap items-center gap-3">
<label
className={cn(
"inline-flex cursor-pointer items-center gap-2 rounded-xl border px-3 py-2 text-xs font-bold transition",
isLight
? "border-slate-200 bg-slate-50 text-slate-800 hover:bg-slate-100"
: "border-white/15 bg-white/5 text-slate-200 hover:bg-white/10",
(uploadingGmailLogo || !activeStoreId) && "pointer-events-none opacity-50",
)}
>
{uploadingGmailLogo ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
Upload
<input
type="file"
accept="image/*"
className="sr-only"
disabled={uploadingGmailLogo || !activeStoreId}
onChange={(e) => {
const f = e.target.files?.[0]
void handleGmailLogoUpload(f ?? null)
e.target.value = ""
}}
/>
</label>
{themeDraft.logoUrl ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={publicAssetUrlForUi(themeDraft.logoUrl)}
alt=""
className="h-10 max-w-[140px] rounded object-contain"
/>
<button
type="button"
onClick={() => setThemeDraft((d) => ({ ...d, logoUrl: null }))}
className={cn(
"text-xs font-bold underline",
isLight ? "text-slate-600" : "text-slate-400",
)}
>
Remove
</button>
</>
) : null}
</div>
<p
className={cn("mt-1 text-[11px]", isLight ? "text-slate-500" : "text-slate-500")}
>
PNG, JPG, or WebP (max 2MB). For the logo to show in real Gmail messages, set
environment variable{" "}
<code className="font-mono text-[10px]">NEXT_PUBLIC_APP_URL</code> to your
public <code className="font-mono text-[10px]">https://</code> app URL (not
localhost).
</p>
</div>
<div>
<label
className={cn(
"text-[11px] font-semibold uppercase tracking-wide",
isLight ? "text-slate-500" : "text-slate-500",
)}
>
Email signature (optional)
</label>
<textarea
value={themeDraft.emailSignature}
onChange={(e) =>
setThemeDraft((d) => ({
...d,
emailSignature: e.target.value.slice(0, 800),
}))
}
rows={4}
placeholder="Jane Doe, Support — Acme Co"
className={cn(
"mt-1.5 w-full resize-y rounded-xl border px-3 py-2 text-sm",
isLight
? "border-slate-200 bg-white text-slate-900 placeholder:text-slate-400"
: "border-white/15 bg-white/5 text-white placeholder:text-slate-500",
)}
/>
<p
className={cn("mt-1 text-[11px]", isLight ? "text-slate-500" : "text-slate-500")}
>
Plain text only. When set, this <strong>replaces</strong> the default line
&quot;— [store] Support (automated)&quot; on AI replies and replaces &quot;Best
regards&quot; on fallback emails. Leave empty to keep the default sign-off.
</p>
</div>
<div>
<label
className={cn(
"text-[11px] font-semibold uppercase tracking-wide",
isLight ? "text-slate-500" : "text-slate-500",
)}
>
Font
</label>
<select
value={themeDraft.fontFamily}
onChange={(e) =>
setThemeDraft((d) => ({ ...d, fontFamily: e.target.value }))
}
className={cn(
"mt-1.5 w-full rounded-xl border px-3 py-2 text-sm",
isLight
? "border-slate-200 bg-white text-slate-900"
: "border-white/15 bg-white/5 text-white",
)}
>
{GMAIL_REPLY_FONT_OPTIONS.map((o) => (
<option
key={o.value}
value={o.value}
className={isLight ? "bg-white text-slate-900" : "bg-slate-900 text-slate-100"}
>
{o.label}
</option>
))}
</select>
<p
className={cn("mt-1 text-[11px]", isLight ? "text-slate-500" : "text-slate-500")}
>
Inter, Poppins, Montserrat, and Nunito load via Google Fonts in clients that
support it; Gmail and others may still fall back to similar system fonts.
</p>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<ColorField
isLight={isLight}
label="Page background"
value={themeDraft.backgroundColor}
onChange={(v) => setThemeDraft((d) => ({ ...d, backgroundColor: v }))}
/>
<ColorField
isLight={isLight}
label="Card background"
value={themeDraft.cardBackground}
onChange={(v) => setThemeDraft((d) => ({ ...d, cardBackground: v }))}
/>
<ColorField
isLight={isLight}
label="Card border"
value={themeDraft.borderColor}
onChange={(v) => setThemeDraft((d) => ({ ...d, borderColor: v }))}
/>
<ColorField
isLight={isLight}
label="Heading text"
value={themeDraft.headingColor}
onChange={(v) => setThemeDraft((d) => ({ ...d, headingColor: v }))}
/>
<ColorField
isLight={isLight}
label="Muted text"
value={themeDraft.mutedColor}
onChange={(v) => setThemeDraft((d) => ({ ...d, mutedColor: v }))}
/>
<ColorField
isLight={isLight}
label="Body / quote text"
value={themeDraft.textColor}
onChange={(v) => setThemeDraft((d) => ({ ...d, textColor: v }))}
/>
<ColorField
isLight={isLight}
label="Quote border"
value={themeDraft.blockquoteBorder}
onChange={(v) => setThemeDraft((d) => ({ ...d, blockquoteBorder: v }))}
/>
<ColorField
isLight={isLight}
label="Quote background"
value={themeDraft.blockquoteBg}
onChange={(v) => setThemeDraft((d) => ({ ...d, blockquoteBg: v }))}
/>
<ColorField
isLight={isLight}
label="Strong emphasis"
value={themeDraft.strongColor}
onChange={(v) => setThemeDraft((d) => ({ ...d, strongColor: v }))}
/>
<ColorField
isLight={isLight}
label="Signature accent"
value={themeDraft.signatureAccent}
onChange={(v) => setThemeDraft((d) => ({ ...d, signatureAccent: v }))}
/>
</div>
</div>
<div className="mt-6 flex flex-wrap gap-2">
<button
type="button"
onClick={() => setThemeDraft(parseGmailReplyThemeJson(null))}
disabled={themeSaving}
className={cn(
"inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-bold disabled:opacity-50",
isLight
? "border-slate-200 bg-slate-50 text-slate-800 hover:bg-slate-100"
: "border-white/15 bg-white/5 text-slate-200 hover:bg-white/10",
)}
>
<RotateCcw className="h-3.5 w-3.5" />
Revert form
</button>
<button
type="button"
onClick={() => void clearGmailTheme()}
disabled={themeSaving}
className={cn(
"inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-bold disabled:opacity-50",
isLight
? "border-red-200 bg-red-50 text-red-700 hover:bg-red-100"
: "border-red-500/30 bg-red-500/10 text-red-300 hover:bg-red-500/20",
)}
>
Clear saved theme
</button>
</div>
</div>
<div
className={cn(
"flex min-h-[320px] flex-col p-4 lg:min-h-0",
isLight ? "bg-slate-50" : "bg-slate-950/50",
)}
>
<p
className={cn(
"mb-2 text-[11px] font-semibold uppercase tracking-wide",
isLight ? "text-slate-500" : "text-slate-500",
)}
>
Preview (sample reply)
</p>
<div
className={cn(
"min-h-0 flex-1 overflow-hidden rounded-xl border shadow-inner",
isLight ? "border-slate-200 bg-white" : "border-white/10 bg-slate-950",
)}
>
<iframe
title="Gmail reply preview"
className="h-full min-h-[280px] w-full border-0"
srcDoc={previewDoc}
sandbox="allow-same-origin"
/>
</div>
</div>
</div>
<div
className={cn(
"flex shrink-0 flex-wrap items-center justify-end gap-2 border-t px-5 py-4",
isLight ? "border-slate-200" : "border-white/10",
)}
>
<button
type="button"
onClick={() => setThemeModalOpen(false)}
className={cn(
"rounded-xl px-4 py-2 text-sm font-bold",
isLight
? "text-slate-600 hover:bg-slate-100"
: "text-slate-300 hover:bg-white/10",
)}
>
Cancel
</button>
<button
type="button"
onClick={() => void saveGmailTheme()}
disabled={themeSaving || !activeStoreId}
className="inline-flex items-center gap-2 rounded-xl bg-indigo-600 px-4 py-2 text-sm font-bold text-white shadow-md shadow-indigo-900/15 hover:bg-indigo-700 disabled:opacity-50"
>
{themeSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Save
</button>
</div>
</div>
</div>,
document.body,
)
if (loading) { if (loading) {
return ( return (
<div className="h-full flex items-center justify-center"> <div className="h-full flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-indigo-400" /> <Loader2
className={cn("w-8 h-8 animate-spin", isLight ? "text-indigo-600" : "text-indigo-400")}
/>
</div> </div>
) )
} }
return ( return (
<div className="h-full flex flex-col space-y-4 md:space-y-6"> <div className="h-full flex flex-col space-y-4 md:space-y-6">
<div className="bg-white/5 backdrop-blur-xl rounded-xl md:rounded-2xl border border-white/10 overflow-hidden p-6 md:p-8"> {themeModal}
<div
className={cn(
"backdrop-blur-xl rounded-xl md:rounded-2xl border overflow-hidden p-6 md:p-8",
isLight
? "border-slate-200 bg-white shadow-sm shadow-slate-900/5"
: "border-white/10 bg-white/5",
)}
>
<div className="flex flex-col items-center text-center space-y-6"> <div className="flex flex-col items-center text-center space-y-6">
<div className={`w-20 h-20 rounded-2xl flex items-center justify-center border-2 ${ <div
isConnected className={cn(
? "bg-emerald-500/20 border-emerald-500/50 text-emerald-400" "w-20 h-20 rounded-2xl flex items-center justify-center border-2",
: "bg-white/10 border-white/20 text-slate-400" isConnected
}`}> ? isLight
? "bg-emerald-50 border-emerald-300 text-emerald-600"
: "bg-emerald-500/20 border-emerald-500/50 text-emerald-400"
: isLight
? "bg-slate-100 border-slate-200 text-slate-500"
: "bg-white/10 border-white/20 text-slate-400",
)}
>
<Mail className="w-10 h-10" /> <Mail className="w-10 h-10" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<h2 className="text-2xl font-extrabold text-white"> <h2
className={cn(
"text-2xl font-extrabold",
isLight ? "text-slate-900" : "text-white",
)}
>
{isConnected ? "Gmail Connected" : "Connect Your Gmail Account"} {isConnected ? "Gmail Connected" : "Connect Your Gmail Account"}
</h2> </h2>
<p className="text-sm text-slate-400 max-w-md"> <p
className={cn("text-sm max-w-md", isLight ? "text-slate-600" : "text-slate-400")}
>
{isConnected {isConnected
? "Your Gmail account is connected and ready to sync emails." ? "Your Gmail account is connected and ready to sync emails."
: "Connect your Gmail account to enable email syncing, ticket creation, and automated responses." : "Connect your Gmail account to enable email syncing, ticket creation, and automated responses."}
}
</p> </p>
</div> </div>
<div className="w-full max-w-md">
<button
type="button"
onClick={() => setThemeModalOpen(true)}
disabled={storeLoading || !activeStoreId}
className={cn(
"flex w-full items-center justify-center gap-2 rounded-xl py-3 text-sm font-bold shadow-md transition disabled:cursor-not-allowed disabled:opacity-40",
isLight
? "border-2 border-indigo-600 bg-indigo-600 text-white hover:bg-indigo-700 hover:border-indigo-700"
: "border border-indigo-400/40 bg-indigo-500 text-white hover:bg-indigo-400",
)}
>
{storeLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Palette className="h-4 w-4" />
)}
Customize email appearance
</button>
{!activeStoreId && !storeLoading && (
<p
className={cn(
"mt-2 text-center text-xs",
isLight ? "text-slate-500" : "text-slate-500",
)}
>
Select a store in your account to edit appearance.
</p>
)}
</div>
{isConnected ? ( {isConnected ? (
<div className="w-full max-w-md space-y-4"> <div className="w-full max-w-md space-y-4">
<div className="bg-emerald-500/10 border border-emerald-500/20 rounded-xl p-4 flex items-center gap-3"> <div
<CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0" /> className={cn(
<div className="flex-1"> "rounded-xl p-4 flex items-center gap-3 border",
<p className="text-sm font-bold text-emerald-400">Connection Active</p> isLight
<p className="text-xs text-slate-400 mt-1"> ? "bg-emerald-50/90 border-emerald-200"
: "bg-emerald-500/10 border-emerald-500/20",
)}
>
<CheckCircle2
className={cn(
"w-5 h-5 shrink-0",
isLight ? "text-emerald-600" : "text-emerald-400",
)}
/>
<div className="flex-1 text-left">
<p
className={cn(
"text-sm font-bold",
isLight ? "text-emerald-800" : "text-emerald-400",
)}
>
Connection Active
</p>
<p
className={cn("text-xs mt-1", isLight ? "text-slate-600" : "text-slate-400")}
>
{gmailEmail ? `Connected to ${gmailEmail}` : "Emails are being synced automatically"} {gmailEmail ? `Connected to ${gmailEmail}` : "Emails are being synced automatically"}
</p> </p>
</div> </div>
@ -152,7 +838,7 @@ function GmailConnectContent() {
onClick={async () => { onClick={async () => {
try { try {
const res = await fetch("/api/integrations/gmail/poll", { const res = await fetch("/api/integrations/gmail/poll", {
method: "POST" method: "POST",
}) })
const data = await res.json() const data = await res.json()
if (res.ok) { if (res.ok) {
@ -160,18 +846,28 @@ function GmailConnectContent() {
} else { } else {
toast.error("Failed to check emails") toast.error("Failed to check emails")
} }
} catch (error) { } catch {
toast.error("Error checking emails") toast.error("Error checking emails")
} }
}} }}
className="flex-1 h-11 px-4 bg-white/10 border border-white/10 rounded-xl font-bold text-white hover:bg-white/15 transition-all flex items-center justify-center gap-2 text-sm" className={cn(
"flex-1 h-11 px-4 rounded-xl font-bold transition-all flex items-center justify-center gap-2 text-sm border",
isLight
? "border-slate-200 bg-slate-50 text-slate-800 hover:bg-slate-100"
: "bg-white/10 border-white/10 text-white hover:bg-white/15",
)}
> >
<Settings className="w-4 h-4" /> <Settings className="w-4 h-4" />
Check Emails Check Emails
</button> </button>
<button <button
onClick={handleDisconnect} onClick={handleDisconnect}
className="flex-1 h-11 px-4 bg-red-500/20 border border-red-500/30 rounded-xl font-bold text-red-400 hover:bg-red-500/30 transition-all text-sm" className={cn(
"flex-1 h-11 px-4 rounded-xl font-bold transition-all text-sm border",
isLight
? "border-red-200 bg-red-50 text-red-700 hover:bg-red-100"
: "bg-red-500/20 border-red-500/30 text-red-400 hover:bg-red-500/30",
)}
> >
Disconnect Disconnect
</button> </button>
@ -179,37 +875,67 @@ function GmailConnectContent() {
</div> </div>
) : ( ) : (
<div className="w-full max-w-md space-y-4"> <div className="w-full max-w-md space-y-4">
<div className="bg-white/5 border border-white/10 rounded-xl p-4 space-y-3"> <div
<div className="flex items-start gap-3"> className={cn(
<CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0 mt-0.5" /> "rounded-xl p-5 space-y-5 border",
<div> isLight
<p className="text-sm font-bold text-white">Email Sync</p> ? "border-slate-200 bg-slate-50/90"
<p className="text-xs text-slate-400 mt-1">Automatically sync incoming emails</p> : "border-white/10 bg-white/5",
)}
>
{(
[
{
title: "Email Sync",
desc: "Automatically sync incoming emails",
},
{
title: "Ticket Creation",
desc: "Create support tickets from emails",
},
{
title: "Auto Responses",
desc: "Send automated replies using L1 AI and your knowledge base (configure under JUNO Engine → Modules)",
},
] as const
).map((row) => (
<div
key={row.title}
className="flex flex-col items-center text-center gap-2 max-w-sm mx-auto"
>
<CheckCircle2
className={cn(
"w-6 h-6 shrink-0",
isLight ? "text-emerald-600" : "text-emerald-400",
)}
aria-hidden
/>
<div>
<p
className={cn(
"text-sm font-bold",
isLight ? "text-slate-900" : "text-white",
)}
>
{row.title}
</p>
<p
className={cn(
"text-xs mt-1 leading-relaxed",
isLight ? "text-slate-600" : "text-slate-400",
)}
>
{row.desc}
</p>
</div>
</div> </div>
</div> ))}
<div className="flex items-start gap-3">
<CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-bold text-white">Ticket Creation</p>
<p className="text-xs text-slate-400 mt-1">Create support tickets from emails</p>
</div>
</div>
<div className="flex items-start gap-3">
<CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-bold text-white">Auto Responses</p>
<p className="text-xs text-slate-400 mt-1">
Send automated replies using L1 AI and your knowledge base (configure
under JUNO Engine → Modules)
</p>
</div>
</div>
</div> </div>
<button <button
onClick={handleConnect} onClick={handleConnect}
disabled={connecting} disabled={connecting}
className="w-full h-12 bg-indigo-500 text-white rounded-xl font-bold hover:bg-indigo-600 border border-indigo-400/20 transition-all flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed" className="w-full h-12 bg-indigo-600 text-white rounded-xl font-bold hover:bg-indigo-700 border-2 border-indigo-600 hover:border-indigo-700 transition-all flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed shadow-md shadow-indigo-900/10"
> >
{connecting ? ( {connecting ? (
<> <>
@ -224,7 +950,12 @@ function GmailConnectContent() {
)} )}
</button> </button>
<p className="text-xs text-slate-500 text-center"> <p
className={cn(
"text-xs text-center",
isLight ? "text-slate-500" : "text-slate-500",
)}
>
By connecting, you authorize JUNO to access your Gmail account for email management purposes. By connecting, you authorize JUNO to access your Gmail account for email management purposes.
</p> </p>
</div> </div>
@ -237,11 +968,13 @@ function GmailConnectContent() {
export default function GmailConnectPage() { export default function GmailConnectPage() {
return ( return (
<Suspense fallback={ <Suspense
<div className="h-full flex items-center justify-center"> fallback={
<Loader2 className="w-8 h-8 animate-spin text-indigo-400" /> <div className="h-full flex items-center justify-center">
</div> <Loader2 className="w-8 h-8 animate-spin text-indigo-400" />
}> </div>
}
>
<GmailConnectContent /> <GmailConnectContent />
</Suspense> </Suspense>
) )

View File

@ -51,25 +51,37 @@ export default function StorefrontChatWidgetPage() {
const otpInputRef = useRef<HTMLInputElement | null>(null) const otpInputRef = useRef<HTMLInputElement | null>(null)
useEffect(() => { useEffect(() => {
// This page is rendered inside a storefront iframe. Force a neutral // Embedded in a storefront iframe: one scroll surface only (the message list).
// full-height canvas to avoid inheriting dark app-shell backgrounds. // If html/body can scroll, the wheel often moves the page scrollbar instead of chat.
const html = document.documentElement const html = document.documentElement
const body = document.body const body = document.body
const prevHtmlBg = html.style.backgroundColor const prevHtmlBg = html.style.backgroundColor
const prevBodyBg = body.style.backgroundColor const prevBodyBg = body.style.backgroundColor
const prevBodyMargin = body.style.margin const prevBodyMargin = body.style.margin
const prevBodyMinHeight = body.style.minHeight const prevBodyMinHeight = body.style.minHeight
const prevHtmlH = html.style.height
const prevHtmlOverflow = html.style.overflow
const prevBodyH = body.style.height
const prevBodyOverflow = body.style.overflow
html.style.backgroundColor = "#ffffff" html.style.backgroundColor = "#ffffff"
body.style.backgroundColor = "#ffffff" body.style.backgroundColor = "#ffffff"
body.style.margin = "0" body.style.margin = "0"
body.style.minHeight = "100vh" html.style.height = "100%"
html.style.overflow = "hidden"
body.style.height = "100%"
body.style.minHeight = "100%"
body.style.overflow = "hidden"
return () => { return () => {
html.style.backgroundColor = prevHtmlBg html.style.backgroundColor = prevHtmlBg
body.style.backgroundColor = prevBodyBg body.style.backgroundColor = prevBodyBg
body.style.margin = prevBodyMargin body.style.margin = prevBodyMargin
body.style.minHeight = prevBodyMinHeight body.style.minHeight = prevBodyMinHeight
html.style.height = prevHtmlH
html.style.overflow = prevHtmlOverflow
body.style.height = prevBodyH
body.style.overflow = prevBodyOverflow
} }
}, []) }, [])
@ -146,6 +158,18 @@ export default function StorefrontChatWidgetPage() {
} }
}, [otpPhase]) }, [otpPhase])
/** Parent storefront often receives wheel events before the iframe; capture + stop on the chat scroller. */
useEffect(() => {
if (!shop) return
const el = messagesScrollRef.current
if (!el) return
const stopWheelBubble = (e: WheelEvent) => {
e.stopPropagation()
}
el.addEventListener("wheel", stopWheelBubble, { capture: true })
return () => el.removeEventListener("wheel", stopWheelBubble, { capture: true })
}, [shop, messages.length])
const sendOtp = async () => { const sendOtp = async () => {
if (!shop || !visitorId) return if (!shop || !visitorId) return
const email = verifyEmail.trim() const email = verifyEmail.trim()
@ -272,10 +296,12 @@ export default function StorefrontChatWidgetPage() {
} }
return ( return (
<div className="flex h-screen w-screen min-h-0 flex-col overflow-hidden bg-white text-slate-900"> <div className="fixed inset-0 z-0 flex min-h-0 w-full flex-col overflow-hidden bg-white text-slate-900">
<div <div
ref={messagesScrollRef} ref={messagesScrollRef}
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-y-contain p-4 bg-gradient-to-b from-white to-slate-50 [scrollbar-gutter:stable]" tabIndex={0}
className="min-h-0 flex-1 touch-pan-y overflow-y-auto overflow-x-hidden overscroll-y-contain p-4 bg-gradient-to-b from-white to-slate-50 [scrollbar-gutter:stable] outline-none focus-visible:ring-2 focus-visible:ring-indigo-400/40"
style={{ WebkitOverflowScrolling: "touch" }}
> >
{sendError ? ( {sendError ? (
<div className="mb-3 px-3 py-2 rounded-xl bg-red-500/10 border border-red-500/20 text-red-600 text-xs font-medium"> <div className="mb-3 px-3 py-2 rounded-xl bg-red-500/10 border border-red-500/20 text-red-600 text-xs font-medium">

View File

@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react"
import { AnimatePresence, motion } from "framer-motion" import { AnimatePresence, motion } from "framer-motion"
import { Loader2, Palette, Save, Upload, WandSparkles, X } from "lucide-react" import { Loader2, Palette, Save, Upload, WandSparkles, X } from "lucide-react"
import { useTheme } from "@/components/ThemeProvider" import { useTheme } from "@/components/ThemeProvider"
import { publicAssetUrlForUi } from "@/lib/utils"
export type StorefrontChatCustomization = { export type StorefrontChatCustomization = {
storefrontChatName: string storefrontChatName: string
@ -288,7 +289,11 @@ export default function StorefrontChatCustomizeModal({
<div className="w-7 h-7 rounded-lg flex items-center justify-center" style={{ backgroundColor: `${form.storefrontChatBrandColor}22` }}> <div className="w-7 h-7 rounded-lg flex items-center justify-center" style={{ backgroundColor: `${form.storefrontChatBrandColor}22` }}>
{form.storefrontChatLogoUrl ? ( {form.storefrontChatLogoUrl ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img src={form.storefrontChatLogoUrl} alt="Logo preview" className="w-5 h-5 object-contain rounded" /> <img
src={publicAssetUrlForUi(form.storefrontChatLogoUrl)}
alt="Logo preview"
className="w-5 h-5 object-contain rounded"
/>
) : ( ) : (
<Palette className="w-4 h-4" style={{ color: form.storefrontChatBrandColor }} /> <Palette className="w-4 h-4" style={{ color: form.storefrontChatBrandColor }} />
)} )}

View File

@ -1,7 +1,6 @@
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { storeSelectGmailL1Context } from "@/lib/prisma/storeSelects" import { storeSelectGmailL1Context } from "@/lib/prisma/storeSelects"
import { logger } from "@/lib/logger" import { logger } from "@/lib/logger"
import { escapeHtmlForEmail } from "@/lib/templates/simpleRenderer"
import { classifyCustomerEmail, type ClassifyCustomerEmailResult } from "@/lib/ai/gmailGroq" import { classifyCustomerEmail, type ClassifyCustomerEmailResult } from "@/lib/ai/gmailGroq"
import { screenInboundEmailForAbuse } from "@/lib/ai/gmailSafety" import { screenInboundEmailForAbuse } from "@/lib/ai/gmailSafety"
import { import {
@ -13,116 +12,23 @@ import { looksLikeOffTopic, looksLikeSensitiveOrderTopic } from "@/lib/ai/gmailO
import { parseEnvFloat } from "@/lib/gmail/gmailAutoReplyGuards" import { parseEnvFloat } from "@/lib/gmail/gmailAutoReplyGuards"
import type { GmailClassification } from "@/lib/ai/gmailTaxonomy" import type { GmailClassification } from "@/lib/ai/gmailTaxonomy"
import { runL1Orchestrator } from "@/lib/ai/orchestrator/runL1" import { runL1Orchestrator } from "@/lib/ai/orchestrator/runL1"
import {
buildHintUnmatchedFallbackHtml,
buildL1HtmlEmail,
buildMinimalFallbackHtml,
buildNeedOrderIdFallbackHtml,
buildOffTopicFallbackHtml,
parseGmailReplyThemeJson,
wrapGmailHtmlDocument,
} from "@/lib/gmail/emailReplyTheme"
export function buildMinimalFallbackHtml(emailBody: string, storeName: string): string { export {
const safeSnippet = escapeHtmlForEmail(emailBody.replace(/<[^>]+>/g, "").slice(0, 400)) buildHintUnmatchedFallbackHtml,
return ` buildL1HtmlEmail,
<div style="font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 24px; line-height: 1.6; color: #0f172a; background-color: #0b1220;"> buildMinimalFallbackHtml,
<div style="max-width: 640px; margin: 0 auto; background: #020617; border-radius: 16px; border: 1px solid #1f2937; padding: 24px;"> buildNeedOrderIdFallbackHtml,
<h1 style="margin: 0 0 12px; font-size: 20px; color: #e5e7eb;">Thanks for reaching out</h1> buildOffTopicFallbackHtml,
<p style="margin: 0 0 12px; color: #9ca3af;"> } from "@/lib/gmail/emailReplyTheme"
We have received your message and our team will review it shortly.
</p>
<p style="margin: 0 0 16px; color: #9ca3af;">Summary of what you sent:</p>
<blockquote style="margin: 0 0 20px; padding: 12px 16px; border-left: 3px solid #6366f1; background: #020617;">
<p style="margin: 0; white-space: pre-wrap; color: #d1d5db; font-size: 14px;">
${safeSnippet || "No additional message content was detected."}
</p>
</blockquote>
<p style="margin: 0 0 8px; color: #9ca3af;">
You can reply to this email if you have more details to share.
</p>
<p style="margin: 0; color: #e5e7eb;">
Best regards,<br/>
<span style="color: #a5b4fc;">${storeName} Support</span>
</p>
</div>
</div>
`
}
/** Fallback when we need an order number before sharing order rows from cache. */
export function buildNeedOrderIdFallbackHtml(storeName: string, storeEmail: string): string {
const contact = storeEmail.trim()
? `Reply with your order number, or email ${storeEmail}.`
: `Reply with your order number from your confirmation email.`
return `
<div style="font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 24px; line-height: 1.6; color: #0f172a; background-color: #0b1220;">
<div style="max-width: 640px; margin: 0 auto; background: #020617; border-radius: 16px; border: 1px solid #1f2937; padding: 24px;">
<p style="margin: 0 0 12px; color: #e5e7eb;">Hello,</p>
<p style="margin: 0 0 12px; color: #9ca3af;">
We didn&apos;t see an order number in your message. To look up the right order for <strong style="color:#e5e7eb;">${storeName}</strong>,
please reply with your order number (for example <strong style="color:#e5e7eb;">#1002</strong> or <strong style="color:#e5e7eb;">1002</strong>) from your order confirmation.
</p>
<p style="margin: 0; color: #9ca3af;">${contact}</p>
<p style="margin: 16px 0 0; color: #e5e7eb;">
Best regards,<br/>
<span style="color: #a5b4fc;">${storeName} Support</span>
</p>
</div>
</div>
`
}
/** When hints did not match cache — no unrelated order rows. */
export function buildHintUnmatchedFallbackHtml(storeName: string, storeEmail: string): string {
const contact = storeEmail.trim()
? `Reply with the correct order number or email us at ${storeEmail}.`
: `Reply with the order number from your confirmation email.`
return `
<div style="font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 24px; line-height: 1.6; color: #0f172a; background-color: #0b1220;">
<div style="max-width: 640px; margin: 0 auto; background: #020617; border-radius: 16px; border: 1px solid #1f2937; padding: 24px;">
<p style="margin: 0 0 12px; color: #e5e7eb;">Hello,</p>
<p style="margin: 0 0 12px; color: #9ca3af;">
We could not match the order number in your message to an order on file for <strong style="color:#e5e7eb;">${storeName}</strong> under this email address.
Please double-check the number or confirm the email you used when placing the order.
</p>
<p style="margin: 0; color: #9ca3af;">${contact}</p>
<p style="margin: 16px 0 0; color: #e5e7eb;">
Best regards,<br/>
<span style="color: #a5b4fc;">${storeName} Support</span>
</p>
</div>
</div>
`
}
function buildL1HtmlEmail(plainBody: string, storeName: string): string {
const safe = escapeHtmlForEmail(plainBody).replace(/\r\n|\n/g, "<br/>")
return `
<div style="font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 24px; line-height: 1.6; color: #0f172a; background-color: #0b1220;">
<div style="max-width: 640px; margin: 0 auto; background: #020617; border-radius: 16px; border: 1px solid #1f2937; padding: 24px;">
<div style="color: #e5e7eb; font-size: 15px; white-space: normal;">${safe}</div>
<p style="margin: 20px 0 0; color: #9ca3af; font-size: 13px;">
— ${escapeHtmlForEmail(storeName)} Support (automated)
</p>
</div>
</div>
`
}
/** Off-topic fallback: polite boundary, no order data. */
export function buildOffTopicFallbackHtml(storeName: string, storeEmail: string): string {
const contact = storeEmail.trim()
? `If you have a question about an order from ${storeName}, reply with details or email us at ${storeEmail}.`
: `If you have a question about an order from ${storeName}, reply with your question and order number if you have one.`
return `
<div style="font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 24px; line-height: 1.6; color: #0f172a; background-color: #0b1220;">
<div style="max-width: 640px; margin: 0 auto; background: #020617; border-radius: 16px; border: 1px solid #1f2937; padding: 24px;">
<p style="margin: 0 0 12px; color: #e5e7eb;">Hello,</p>
<p style="margin: 0 0 12px; color: #9ca3af;">
This inbox only handles orders, products, shipping, returns, and billing for <strong style="color:#e5e7eb;">${storeName}</strong>.
We can&apos;t answer unrelated requests (news, weather, other websites, or general topics).
</p>
<p style="margin: 0; color: #9ca3af;">${contact}</p>
<p style="margin: 16px 0 0; color: #e5e7eb;">
Best regards,<br/>
<span style="color: #a5b4fc;">${storeName} Support</span>
</p>
</div>
</div>
`
}
export type AutoReplyPreviewInput = { export type AutoReplyPreviewInput = {
storeId: string storeId: string
@ -168,9 +74,10 @@ export async function generateAutoReplyPreview(
if (safety?.flagged) { if (safety?.flagged) {
const store = await prisma.store.findUnique({ const store = await prisma.store.findUnique({
where: { id: input.storeId }, where: { id: input.storeId },
select: { businessName: true, email: true }, select: { businessName: true, email: true, gmailReplyTheme: true },
}) })
const storeName = store?.businessName || "Your store" const storeName = store?.businessName || "Your store"
const replyTheme = parseGmailReplyThemeJson(store?.gmailReplyTheme)
return { return {
classification: { intent: "general", mood: "neutral", confidence: 0.0, short_summary: "" }, classification: { intent: "general", mood: "neutral", confidence: 0.0, short_summary: "" },
classifySource: "fallback", classifySource: "fallback",
@ -188,7 +95,10 @@ export async function generateAutoReplyPreview(
orderSummaryForContext: "", orderSummaryForContext: "",
ticketSummary: "", ticketSummary: "",
usedFallback: "minimal", usedFallback: "minimal",
html: buildMinimalFallbackHtml(emailBody, storeName), html: wrapGmailHtmlDocument(
buildMinimalFallbackHtml(emailBody, storeName, replyTheme),
replyTheme,
),
flagged: true, flagged: true,
flaggedReason: safety.reason, flaggedReason: safety.reason,
l1Used: false, l1Used: false,
@ -272,6 +182,8 @@ export async function generateAutoReplyPreview(
}) })
const storeName = store?.businessName || "Your store" const storeName = store?.businessName || "Your store"
const replyTheme = parseGmailReplyThemeJson(store?.gmailReplyTheme)
const ticketSummary = messageTooSparse const ticketSummary = messageTooSparse
? "We could not read enough text in your message to look up an order automatically. Please reply with a short description and your order number if you have one." ? "We could not read enough text in your message to look up an order automatically. Please reply with a short description and your order number if you have one."
: classification.short_summary?.trim() || plainForAi.slice(0, 500) || emailBody.slice(0, 500) : classification.short_summary?.trim() || plainForAi.slice(0, 500) || emailBody.slice(0, 500)
@ -332,7 +244,7 @@ export async function generateAutoReplyPreview(
emailFrom: senderEmail, emailFrom: senderEmail,
}) })
if (l1.text.trim().length > 0) { if (l1.text.trim().length > 0) {
html = buildL1HtmlEmail(l1.text.trim(), storeName) html = buildL1HtmlEmail(l1.text.trim(), storeName, replyTheme)
l1Used = true l1Used = true
} }
} catch (e) { } catch (e) {
@ -344,16 +256,16 @@ export async function generateAutoReplyPreview(
if (!html) { if (!html) {
if (isOffTopic) { if (isOffTopic) {
usedFallback = "off_topic" usedFallback = "off_topic"
html = buildOffTopicFallbackHtml(storeName, store?.email || "") html = buildOffTopicFallbackHtml(storeName, store?.email || "", replyTheme)
} else if (needsOrderNumberFirst) { } else if (needsOrderNumberFirst) {
usedFallback = "need_order_id" usedFallback = "need_order_id"
html = buildNeedOrderIdFallbackHtml(storeName, store?.email || "") html = buildNeedOrderIdFallbackHtml(storeName, store?.email || "", replyTheme)
} else if (hintUnmatched) { } else if (hintUnmatched) {
usedFallback = "hint_unmatched" usedFallback = "hint_unmatched"
html = buildHintUnmatchedFallbackHtml(storeName, store?.email || "") html = buildHintUnmatchedFallbackHtml(storeName, store?.email || "", replyTheme)
} else { } else {
usedFallback = "minimal" usedFallback = "minimal"
html = buildMinimalFallbackHtml(emailBody, storeName) html = buildMinimalFallbackHtml(emailBody, storeName, replyTheme)
} }
} }
@ -374,9 +286,8 @@ export async function generateAutoReplyPreview(
orderSummaryForContext, orderSummaryForContext,
ticketSummary, ticketSummary,
usedFallback, usedFallback,
html, html: wrapGmailHtmlDocument(html, replyTheme),
flagged: false, flagged: false,
l1Used, l1Used,
} }
} }

View File

@ -0,0 +1,385 @@
import { z } from "zod"
import { escapeHtmlForEmail } from "@/lib/templates/simpleRenderer"
import { absoluteUrlForEmailImages } from "@/lib/utils"
const HEX = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/
/** Default look matches legacy hardcoded auto-reply HTML (dark card). */
export const DEFAULT_GMAIL_REPLY_THEME = {
backgroundColor: "#0b1220",
cardBackground: "#020617",
borderColor: "#1f2937",
headingColor: "#e5e7eb",
mutedColor: "#9ca3af",
textColor: "#d1d5db",
blockquoteBorder: "#6366f1",
blockquoteBg: "#020617",
strongColor: "#e5e7eb",
signatureAccent: "#a5b4fc",
fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
logoUrl: null as string | null,
/** Plain-text lines shown at the bottom of every auto-reply (HTML stripped on save). */
emailSignature: "",
} as const
export type ResolvedGmailReplyTheme = {
backgroundColor: string
cardBackground: string
borderColor: string
headingColor: string
mutedColor: string
textColor: string
blockquoteBorder: string
blockquoteBg: string
strongColor: string
signatureAccent: string
fontFamily: string
logoUrl: string | null
emailSignature: string
}
export const GMAIL_REPLY_FONT_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [
{
value: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
label: "System / Segoe UI",
},
{
value: "system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif",
label: "System UI stack",
},
{
value: "Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif",
label: "Inter",
},
{
value: "Poppins, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif",
label: "Poppins",
},
{
value: "Montserrat, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif",
label: "Montserrat",
},
{
value: "Nunito, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif",
label: "Nunito",
},
]
function sanitizeHexColor(value: unknown, fallback: string): string {
if (typeof value !== "string") return fallback
const v = value.trim()
return HEX.test(v) ? v : fallback
}
function sanitizeLogoUrl(value: unknown): string | null {
if (typeof value !== "string") return null
const v = value.trim()
if (!v) return null
if (v.startsWith("/") && !v.includes("..")) {
return v.slice(0, 500)
}
try {
const parsed = new URL(v)
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null
return parsed.toString().slice(0, 500)
} catch {
return null
}
}
const ALLOWED_FONTS = new Set(GMAIL_REPLY_FONT_OPTIONS.map((o) => o.value))
function sanitizeFont(value: unknown, fallback: string): string {
if (typeof value !== "string") return fallback
const v = value.trim()
return ALLOWED_FONTS.has(v) ? v : fallback
}
const MAX_EMAIL_SIGNATURE = 800
function sanitizeEmailSignature(value: unknown): string {
if (typeof value !== "string") return ""
const stripped = value.replace(/<[^>]*>/g, "").trim()
return stripped.slice(0, MAX_EMAIL_SIGNATURE)
}
/**
* Merge user JSON with defaults; invalid fields fall back safely.
*/
export function parseGmailReplyThemeJson(raw: unknown): ResolvedGmailReplyTheme {
const d = DEFAULT_GMAIL_REPLY_THEME
if (raw === null || raw === undefined) {
return { ...d, logoUrl: null, emailSignature: "" }
}
if (typeof raw !== "object" || Array.isArray(raw)) {
return { ...d, logoUrl: null, emailSignature: "" }
}
const o = raw as Record<string, unknown>
return {
backgroundColor: sanitizeHexColor(o.backgroundColor, d.backgroundColor),
cardBackground: sanitizeHexColor(o.cardBackground, d.cardBackground),
borderColor: sanitizeHexColor(o.borderColor, d.borderColor),
headingColor: sanitizeHexColor(o.headingColor, d.headingColor),
mutedColor: sanitizeHexColor(o.mutedColor, d.mutedColor),
textColor: sanitizeHexColor(o.textColor, d.textColor),
blockquoteBorder: sanitizeHexColor(o.blockquoteBorder, d.blockquoteBorder),
blockquoteBg: sanitizeHexColor(o.blockquoteBg, d.blockquoteBg),
strongColor: sanitizeHexColor(o.strongColor, d.strongColor),
signatureAccent: sanitizeHexColor(o.signatureAccent, d.signatureAccent),
fontFamily: sanitizeFont(o.fontFamily, d.fontFamily),
logoUrl: sanitizeLogoUrl(o.logoUrl),
emailSignature: sanitizeEmailSignature(o.emailSignature),
}
}
export const gmailReplyThemeBodySchema = z
.object({
backgroundColor: z.string().optional(),
cardBackground: z.string().optional(),
borderColor: z.string().optional(),
headingColor: z.string().optional(),
mutedColor: z.string().optional(),
textColor: z.string().optional(),
blockquoteBorder: z.string().optional(),
blockquoteBg: z.string().optional(),
strongColor: z.string().optional(),
signatureAccent: z.string().optional(),
fontFamily: z.string().optional(),
logoUrl: z.string().nullable().optional(),
emailSignature: z.string().max(MAX_EMAIL_SIGNATURE).optional(),
})
.strict()
export type GmailReplyThemeBody = z.infer<typeof gmailReplyThemeBodySchema>
export function resolvedThemeFromBody(body: GmailReplyThemeBody): ResolvedGmailReplyTheme {
return parseGmailReplyThemeJson(body)
}
function logoBlock(theme: ResolvedGmailReplyTheme): string {
if (!theme.logoUrl?.trim()) return ""
const abs = absoluteUrlForEmailImages(theme.logoUrl.trim())
const src = escapeHtmlForEmail(abs)
return `<div style="margin:0 0 18px;text-align:center;">
<img src="${src}" alt="" width="220" style="max-height:48px;max-width:220px;height:auto;object-fit:contain;display:inline-block;border:0;outline:none;" />
</div>`
}
/** Optional vendor-written footer (plain text, line breaks preserved). */
function vendorSignatureBlock(theme: ResolvedGmailReplyTheme): string {
const raw = theme.emailSignature?.trim()
if (!raw) return ""
const safe = escapeHtmlForEmail(raw).replace(/\r\n|\n/g, "<br/>")
return `<div style="margin-top:20px;padding-top:18px;border-top:1px solid ${theme.borderColor};color:${theme.mutedColor};font-size:13px;line-height:1.65;font-family:${theme.fontFamily};">
${safe}
</div>`
}
/** L1 replies: custom signature replaces the default "— Store Support (automated)" line. */
function l1ClosingBlock(theme: ResolvedGmailReplyTheme, storeName: string): string {
if (theme.emailSignature?.trim()) {
return vendorSignatureBlock(theme)
}
return `<p style="margin: 20px 0 0; color: ${theme.mutedColor}; font-size: 13px;">
— ${escapeHtmlForEmail(storeName)} Support (automated)
</p>`
}
/** Fallback templates: custom signature replaces "Best regards, … Support". */
function fallbackClosingBlock(
theme: ResolvedGmailReplyTheme,
storeName: string,
topMargin: "0" | "16px",
): string {
if (theme.emailSignature?.trim()) {
return vendorSignatureBlock(theme)
}
const margin = topMargin === "0" ? "0" : "16px 0 0"
return `<p style="margin: ${margin}; color: ${theme.headingColor};">
Best regards,<br/>
<span style="color: ${theme.signatureAccent};">${escapeHtmlForEmail(storeName)} Support</span>
</p>`
}
/**
* Google Fonts stylesheet for non-system stacks so webfonts load in Gmail and other clients that allow &lt;link&gt;.
*/
function googleFontsLinkTag(fontFamily: string): string {
const ff = fontFamily
const spec: Array<{ test: (s: string) => boolean; href: string }> = [
{
test: (s) => s.includes("Inter,") || s.includes("'Inter'"),
href: "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap",
},
{
test: (s) => s.includes("Poppins,") || s.includes("'Poppins'"),
href: "https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap",
},
{
test: (s) => s.includes("Montserrat,") || s.includes("'Montserrat'"),
href: "https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap",
},
{
test: (s) => s.includes("Nunito,") || s.includes("'Nunito'"),
href: "https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700&display=swap",
},
]
for (const { test, href } of spec) {
if (test(ff)) {
return `<link rel="stylesheet" href="${escapeHtmlForEmail(href)}" />`
}
}
return ""
}
/**
* Wrap fragment in a minimal HTML document so Google Fonts and charset apply. Many clients still fall back to system fonts.
*/
export function wrapGmailHtmlDocument(bodyFragment: string, theme: ResolvedGmailReplyTheme): string {
const fontLink = googleFontsLinkTag(theme.fontFamily)
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
${fontLink}
</head>
<body style="margin:0;padding:0;">
${bodyFragment}
</body>
</html>`
}
/**
* Sample outbound reply for the vendor preview (same structure as L1 wrapper).
*/
export function buildGmailReplyDemoHtml(storeName: string, theme?: ResolvedGmailReplyTheme): string {
const t = theme ?? parseGmailReplyThemeJson(null)
const plain =
"Thanks for reaching out! Your order #1002 has shipped and should arrive by Friday. " +
"You can track it from your confirmation email. If you need anything else, just reply to this message."
return buildL1HtmlEmail(plain, storeName, t)
}
export function buildL1HtmlEmail(
plainBody: string,
storeName: string,
theme?: ResolvedGmailReplyTheme,
): string {
const t = theme ?? parseGmailReplyThemeJson(null)
const safe = escapeHtmlForEmail(plainBody).replace(/\r\n|\n/g, "<br/>")
return `
<div style="font-family: ${t.fontFamily}; padding: 24px; line-height: 1.6; color: ${t.headingColor}; background-color: ${t.backgroundColor};">
<div style="max-width: 640px; margin: 0 auto; background: ${t.cardBackground}; border-radius: 16px; border: 1px solid ${t.borderColor}; padding: 24px;">
${logoBlock(t)}
<div style="color: ${t.headingColor}; font-size: 15px; white-space: normal;">${safe}</div>
${l1ClosingBlock(t, storeName)}
</div>
</div>
`
}
export function buildMinimalFallbackHtml(
emailBody: string,
storeName: string,
theme?: ResolvedGmailReplyTheme,
): string {
const t = theme ?? parseGmailReplyThemeJson(null)
const safeSnippet = escapeHtmlForEmail(emailBody.replace(/<[^>]+>/g, "").slice(0, 400))
return `
<div style="font-family: ${t.fontFamily}; padding: 24px; line-height: 1.6; color: ${t.headingColor}; background-color: ${t.backgroundColor};">
<div style="max-width: 640px; margin: 0 auto; background: ${t.cardBackground}; border-radius: 16px; border: 1px solid ${t.borderColor}; padding: 24px;">
${logoBlock(t)}
<h1 style="margin: 0 0 12px; font-size: 20px; color: ${t.headingColor};">Thanks for reaching out</h1>
<p style="margin: 0 0 12px; color: ${t.mutedColor};">
We have received your message and our team will review it shortly.
</p>
<p style="margin: 0 0 16px; color: ${t.mutedColor};">Summary of what you sent:</p>
<blockquote style="margin: 0 0 20px; padding: 12px 16px; border-left: 3px solid ${t.blockquoteBorder}; background: ${t.blockquoteBg};">
<p style="margin: 0; white-space: pre-wrap; color: ${t.textColor}; font-size: 14px;">
${safeSnippet || "No additional message content was detected."}
</p>
</blockquote>
<p style="margin: 0 0 8px; color: ${t.mutedColor};">
You can reply to this email if you have more details to share.
</p>
${fallbackClosingBlock(t, storeName, "0")}
</div>
</div>
`
}
export function buildNeedOrderIdFallbackHtml(
storeName: string,
storeEmail: string,
theme?: ResolvedGmailReplyTheme,
): string {
const t = theme ?? parseGmailReplyThemeJson(null)
const contact = storeEmail.trim()
? `Reply with your order number, or email ${storeEmail}.`
: `Reply with your order number from your confirmation email.`
return `
<div style="font-family: ${t.fontFamily}; padding: 24px; line-height: 1.6; color: ${t.headingColor}; background-color: ${t.backgroundColor};">
<div style="max-width: 640px; margin: 0 auto; background: ${t.cardBackground}; border-radius: 16px; border: 1px solid ${t.borderColor}; padding: 24px;">
${logoBlock(t)}
<p style="margin: 0 0 12px; color: ${t.headingColor};">Hello,</p>
<p style="margin: 0 0 12px; color: ${t.mutedColor};">
We didn&apos;t see an order number in your message. To look up the right order for <strong style="color:${t.strongColor};">${storeName}</strong>,
please reply with your order number (for example <strong style="color:${t.strongColor};">#1002</strong> or <strong style="color:${t.strongColor};">1002</strong>) from your order confirmation.
</p>
<p style="margin: 0; color: ${t.mutedColor};">${contact}</p>
${fallbackClosingBlock(t, storeName, "16px")}
</div>
</div>
`
}
export function buildHintUnmatchedFallbackHtml(
storeName: string,
storeEmail: string,
theme?: ResolvedGmailReplyTheme,
): string {
const t = theme ?? parseGmailReplyThemeJson(null)
const contact = storeEmail.trim()
? `Reply with the correct order number or email us at ${storeEmail}.`
: `Reply with the order number from your confirmation email.`
return `
<div style="font-family: ${t.fontFamily}; padding: 24px; line-height: 1.6; color: ${t.headingColor}; background-color: ${t.backgroundColor};">
<div style="max-width: 640px; margin: 0 auto; background: ${t.cardBackground}; border-radius: 16px; border: 1px solid ${t.borderColor}; padding: 24px;">
${logoBlock(t)}
<p style="margin: 0 0 12px; color: ${t.headingColor};">Hello,</p>
<p style="margin: 0 0 12px; color: ${t.mutedColor};">
We could not match the order number in your message to an order on file for <strong style="color:${t.strongColor};">${storeName}</strong> under this email address.
Please double-check the number or confirm the email you used when placing the order.
</p>
<p style="margin: 0; color: ${t.mutedColor};">${contact}</p>
${fallbackClosingBlock(t, storeName, "16px")}
</div>
</div>
`
}
export function buildOffTopicFallbackHtml(
storeName: string,
storeEmail: string,
theme?: ResolvedGmailReplyTheme,
): string {
const t = theme ?? parseGmailReplyThemeJson(null)
const contact = storeEmail.trim()
? `If you have a question about an order from ${storeName}, reply with details or email us at ${storeEmail}.`
: `If you have a question about an order from ${storeName}, reply with your question and order number if you have one.`
return `
<div style="font-family: ${t.fontFamily}; padding: 24px; line-height: 1.6; color: ${t.headingColor}; background-color: ${t.backgroundColor};">
<div style="max-width: 640px; margin: 0 auto; background: ${t.cardBackground}; border-radius: 16px; border: 1px solid ${t.borderColor}; padding: 24px;">
${logoBlock(t)}
<p style="margin: 0 0 12px; color: ${t.headingColor};">Hello,</p>
<p style="margin: 0 0 12px; color: ${t.mutedColor};">
This inbox only handles orders, products, shipping, returns, and billing for <strong style="color:${t.strongColor};">${storeName}</strong>.
We can&apos;t answer unrelated requests (news, weather, other websites, or general topics).
</p>
<p style="margin: 0; color: ${t.mutedColor};">${contact}</p>
${fallbackClosingBlock(t, storeName, "16px")}
</div>
</div>
`
}

View File

@ -30,6 +30,7 @@ export const storeSelectGmailL1Context = {
categoryMetadetailsEnabled: true, categoryMetadetailsEnabled: true,
aiModules: true, aiModules: true,
sharedFieldAnswers: true, sharedFieldAnswers: true,
gmailReplyTheme: true,
} as const } as const
export const storeSelectPatchStorefrontAi = { export const storeSelectPatchStorefrontAi = {
@ -43,6 +44,7 @@ export const storeSelectPatchStorefrontAi = {
aiModules: true, aiModules: true,
modulePolicies: true, modulePolicies: true,
sharedFieldAnswers: true, sharedFieldAnswers: true,
gmailReplyTheme: true,
} as const } as const
/** Session / OTP fields for L1 storefront replies */ /** Session / OTP fields for L1 storefront replies */

View File

@ -11,3 +11,58 @@ export function getBaseUrl() {
if (process.env.NEXT_PUBLIC_APP_URL) return process.env.NEXT_PUBLIC_APP_URL if (process.env.NEXT_PUBLIC_APP_URL) return process.env.NEXT_PUBLIC_APP_URL
return "http://localhost:3000" return "http://localhost:3000"
} }
/** Turn a relative `/uploads/...` path into an absolute URL (emails, server-side HTML). */
export function absoluteUrlForPublicPath(path: string | null | undefined): string {
if (!path?.trim()) return ""
const p = path.trim()
if (/^https?:\/\//i.test(p)) return p
const base = getBaseUrl().replace(/\/$/, "")
const pathPart = p.startsWith("/") ? p : `/${p}`
return `${base}${pathPart}`
}
/**
* Base URL for images embedded in outbound email HTML. Gmail requires a **public HTTPS** URL;
* prefer `NEXT_PUBLIC_APP_URL` (production) over localhost so `<img src>` resolves for recipients.
*/
export function getEmailAssetBaseUrl(): string {
if (process.env.NEXT_PUBLIC_APP_URL?.trim()) {
return process.env.NEXT_PUBLIC_APP_URL.trim().replace(/\/$/, "")
}
if (process.env.VERCEL_URL?.trim()) {
return `https://${process.env.VERCEL_URL.trim()}`.replace(/\/$/, "")
}
if (process.env.AUTH_URL?.trim()) {
return process.env.AUTH_URL.trim().replace(/\/$/, "")
}
if (process.env.NEXTAUTH_URL?.trim()) {
return process.env.NEXTAUTH_URL.trim().replace(/\/$/, "")
}
return "http://localhost:3000"
}
/** Use for `<img src>` in Gmail / email bodies (not for in-browser preview). */
export function absoluteUrlForEmailImages(path: string | null | undefined): string {
if (!path?.trim()) return ""
const p = path.trim()
if (/^https?:\/\//i.test(p)) return p
const base = getEmailAssetBaseUrl()
const pathPart = p.startsWith("/") ? p : `/${p}`
return `${base}${pathPart}`
}
/**
* Client-side preview: same-origin absolute URL so `/uploads/...` images load in img/src and iframes.
* Server: falls back to absoluteUrlForPublicPath.
*/
export function publicAssetUrlForUi(path: string | null | undefined): string {
if (!path?.trim()) return ""
const p = path.trim()
if (/^https?:\/\//i.test(p)) return p
if (typeof window !== "undefined") {
const origin = window.location.origin
return `${origin}${p.startsWith("/") ? p : `/${p}`}`
}
return absoluteUrlForPublicPath(p)
}