feat & fixes: Gmail appearence feature & UI fixes
This commit is contained in:
parent
61b4db37c0
commit
f3cedae713
303
docs/ai-modules-shoe-store-sample-answers.md
Normal file
303
docs/ai-modules-shoe-store-sample-answers.md
Normal 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.*
|
||||||
@ -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"
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Store" ADD COLUMN IF NOT EXISTS "gmailReplyTheme" JSONB;
|
||||||
@ -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[]
|
||||||
|
|||||||
25
scripts/prisma-baseline-record-all-applied.sh
Executable file
25
scripts/prisma-baseline-record-all-applied.sh
Executable 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"
|
||||||
@ -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)
|
||||||
|
|||||||
65
src/app/api/uploads/gmail-reply-logo/route.ts
Normal file
65
src/app/api/uploads/gmail-reply-logo/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
"— [store] Support (automated)" on AI replies and replaces "Best
|
||||||
|
regards" 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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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 }} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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'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'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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
385
src/lib/gmail/emailReplyTheme.ts
Normal file
385
src/lib/gmail/emailReplyTheme.ts
Normal 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 <link>.
|
||||||
|
*/
|
||||||
|
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'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'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>
|
||||||
|
`
|
||||||
|
}
|
||||||
@ -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 */
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user