fix: UI fixes for Modules, Customer, Storefront
This commit is contained in:
parent
2235ab6770
commit
61b4db37c0
@ -1,16 +1,21 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import { createPortal } from "react-dom"
|
||||||
|
import toast from "react-hot-toast"
|
||||||
import {
|
import {
|
||||||
Users,
|
Users,
|
||||||
Search,
|
Search,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Mail,
|
Mail,
|
||||||
Plus,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
MapPin
|
MapPin,
|
||||||
|
ExternalLink,
|
||||||
|
Copy,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
import { useTheme } from "@/components/ThemeProvider"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
interface ShopifyCustomer {
|
interface ShopifyCustomer {
|
||||||
id: number
|
id: number
|
||||||
@ -45,11 +50,93 @@ function formatCustomerLocation(default_address: ShopifyCustomer["default_addres
|
|||||||
return "Not provided"
|
return "Not provided"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MENU_WIDTH_PX = 200
|
||||||
|
|
||||||
export default function CustomersPage() {
|
export default function CustomersPage() {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const isLight = theme === "light"
|
||||||
|
|
||||||
const [customers, setCustomers] = useState<ShopifyCustomer[]>([])
|
const [customers, setCustomers] = useState<ShopifyCustomer[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
const [searchTerm, setSearchTerm] = useState("")
|
const [searchTerm, setSearchTerm] = useState("")
|
||||||
|
const [openMenuCustomerId, setOpenMenuCustomerId] = useState<number | null>(null)
|
||||||
|
const [menuRect, setMenuRect] = useState<{ top: number; left: number } | null>(null)
|
||||||
|
const [storeDomain, setStoreDomain] = useState<string | null>(null)
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchStoreInfo = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/stores")
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
const activeStore = data.stores?.find((s: { isActive: boolean }) => s.isActive)
|
||||||
|
if (activeStore?.shopifyDomain) {
|
||||||
|
const clean = String(activeStore.shopifyDomain)
|
||||||
|
.replace(/^https?:\/\//, "")
|
||||||
|
.replace(/\/$/, "")
|
||||||
|
setStoreDomain(clean)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void fetchStoreInfo()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const closeMenu = useCallback(() => {
|
||||||
|
setOpenMenuCustomerId(null)
|
||||||
|
setMenuRect(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (openMenuCustomerId === null) return
|
||||||
|
const onScroll = () => closeMenu()
|
||||||
|
window.addEventListener("scroll", onScroll, true)
|
||||||
|
return () => window.removeEventListener("scroll", onScroll, true)
|
||||||
|
}, [openMenuCustomerId, closeMenu])
|
||||||
|
|
||||||
|
const handleMenuToggle = (customerId: number, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (openMenuCustomerId === customerId) {
|
||||||
|
closeMenu()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||||
|
const left = Math.min(
|
||||||
|
Math.max(8, rect.right - MENU_WIDTH_PX),
|
||||||
|
window.innerWidth - MENU_WIDTH_PX - 8,
|
||||||
|
)
|
||||||
|
setMenuRect({ top: rect.bottom + 4, left })
|
||||||
|
setOpenMenuCustomerId(customerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewInShopify = (customerId: number) => {
|
||||||
|
if (storeDomain) {
|
||||||
|
window.open(`https://${storeDomain}/admin/customers/${customerId}`, "_blank")
|
||||||
|
} else {
|
||||||
|
toast.error("Store domain not found. Check your store configuration.")
|
||||||
|
}
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyCustomerId = (customerId: number) => {
|
||||||
|
void navigator.clipboard.writeText(String(customerId))
|
||||||
|
toast.success("Customer ID copied")
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyEmail = (email: string) => {
|
||||||
|
void navigator.clipboard.writeText(email)
|
||||||
|
toast.success("Email copied")
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchCustomers = async () => {
|
const fetchCustomers = async () => {
|
||||||
@ -84,16 +171,9 @@ export default function CustomersPage() {
|
|||||||
|
|
||||||
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="flex flex-col sm:flex-row sm:items-center justify-between gap-4 flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<div>
|
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">Customers</h1>
|
||||||
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">Customers</h1>
|
<p className="text-sm md:text-base text-slate-400 mt-1">View and manage your Shopify customer base.</p>
|
||||||
<p className="text-sm md:text-base text-slate-400 mt-1">View and manage your Shopify customer base.</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button className="h-10 md:h-11 px-4 md:px-6 bg-indigo-500 text-white rounded-xl font-bold flex items-center gap-2 hover:bg-indigo-600 border border-indigo-400/20 transition-all text-sm">
|
|
||||||
<Plus className="w-4 h-4" /> <span className="hidden sm:inline">Add Customer</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white/5 backdrop-blur-xl rounded-xl md:rounded-2xl border border-white/10 overflow-hidden flex flex-col flex-1 min-h-0">
|
<div className="bg-white/5 backdrop-blur-xl rounded-xl md:rounded-2xl border border-white/10 overflow-hidden flex flex-col flex-1 min-h-0">
|
||||||
@ -180,8 +260,23 @@ export default function CustomersPage() {
|
|||||||
<span className="text-xs text-slate-500 font-medium">{new Date(customer.created_at).toLocaleDateString()}</span>
|
<span className="text-xs text-slate-500 font-medium">{new Date(customer.created_at).toLocaleDateString()}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 md:px-8 py-3 md:py-4 text-right">
|
<td className="px-4 md:px-8 py-3 md:py-4 text-right">
|
||||||
<button className="p-2 hover:bg-white/10 rounded-lg text-slate-400 hover:text-indigo-400 transition-all">
|
<button
|
||||||
<MoreHorizontal className="w-4 h-4 md:w-5 md:h-5" />
|
type="button"
|
||||||
|
aria-expanded={openMenuCustomerId === customer.id}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
onClick={(e) => handleMenuToggle(customer.id, e)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg p-2 transition-all",
|
||||||
|
isLight
|
||||||
|
? openMenuCustomerId === customer.id
|
||||||
|
? "bg-indigo-50 text-indigo-600"
|
||||||
|
: "text-slate-500 hover:bg-slate-100 hover:text-indigo-600"
|
||||||
|
: openMenuCustomerId === customer.id
|
||||||
|
? "bg-white/10 text-indigo-400"
|
||||||
|
: "text-slate-400 hover:bg-white/10 hover:text-indigo-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4 md:h-5 md:w-5" />
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -192,6 +287,75 @@ export default function CustomersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{mounted &&
|
||||||
|
openMenuCustomerId !== null &&
|
||||||
|
menuRect !== null &&
|
||||||
|
createPortal(
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[100]"
|
||||||
|
aria-hidden
|
||||||
|
onClick={closeMenu}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: menuRect.top,
|
||||||
|
left: menuRect.left,
|
||||||
|
width: MENU_WIDTH_PX,
|
||||||
|
zIndex: 101,
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden rounded-xl border shadow-xl backdrop-blur-xl",
|
||||||
|
isLight
|
||||||
|
? "border-slate-200 bg-white"
|
||||||
|
: "border-white/10 bg-slate-800/95",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
const c = customers.find((x) => x.id === openMenuCustomerId)
|
||||||
|
if (!c) return null
|
||||||
|
const itemCls = isLight
|
||||||
|
? "flex w-full items-center gap-2 px-4 py-2.5 text-left text-sm font-medium text-slate-700 transition-colors hover:bg-indigo-50 hover:text-indigo-700"
|
||||||
|
: "flex w-full items-center gap-2 px-4 py-2.5 text-left text-sm font-medium text-slate-200 transition-colors hover:bg-indigo-500 hover:text-white"
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => viewInShopify(c.id)}
|
||||||
|
className={itemCls}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4 shrink-0" />
|
||||||
|
View in Shopify
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => copyCustomerId(c.id)}
|
||||||
|
className={itemCls}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4 shrink-0" />
|
||||||
|
Copy customer ID
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => copyEmail(c.email)}
|
||||||
|
className={itemCls}
|
||||||
|
>
|
||||||
|
<Mail className="h-4 w-4 shrink-0" />
|
||||||
|
Copy email
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,15 +52,15 @@ export default function JunoEngineLayout({
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col space-y-4 md:space-y-6">
|
<div className="flex w-full flex-col gap-4 md:gap-6">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 flex-shrink-0">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">JUNO Engine</h1>
|
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">JUNO Engine</h1>
|
||||||
<p className="text-sm md:text-base text-slate-400 mt-1">Manage your communication and support infrastructure.</p>
|
<p className="text-sm md:text-base text-slate-400 mt-1">Manage your communication and support infrastructure.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 md:gap-3 border-b border-white/10 overflow-x-auto flex-shrink-0">
|
<div className="flex gap-2 md:gap-3 border-b border-white/10 overflow-x-auto shrink-0">
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const isActive = pathname === tab.href
|
const isActive = pathname === tab.href
|
||||||
const Icon = tab.icon
|
const Icon = tab.icon
|
||||||
@ -80,9 +80,7 @@ export default function JunoEngineLayout({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="min-h-0 w-full flex-1">{children}</div>
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -728,9 +728,9 @@ export default function JunoEngineModulesPage() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-0 flex-1 pb-28">
|
<div className="flex w-full flex-col gap-6">
|
||||||
<div className="flex-shrink-0 space-y-5 mb-6">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
<h1
|
<h1
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-2xl md:text-3xl font-extrabold tracking-tight",
|
"text-2xl md:text-3xl font-extrabold tracking-tight",
|
||||||
@ -741,7 +741,7 @@ export default function JunoEngineModulesPage() {
|
|||||||
</h1>
|
</h1>
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm mt-1 max-w-2xl",
|
"text-sm max-w-2xl",
|
||||||
isLight ? "text-slate-600" : "text-slate-400",
|
isLight ? "text-slate-600" : "text-slate-400",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -756,7 +756,48 @@ export default function JunoEngineModulesPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-xs pt-1",
|
||||||
|
isLight ? "text-slate-500" : "text-slate-500",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Changes are local until you save.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{activeStoreId && !loading ? (
|
||||||
|
<div className="flex shrink-0 flex-col items-stretch gap-2 sm:items-end">
|
||||||
|
{toast ? (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"max-w-xs text-right text-xs sm:max-w-sm",
|
||||||
|
toast.includes("fail") || toast.includes("Failed")
|
||||||
|
? isLight
|
||||||
|
? "text-amber-700"
|
||||||
|
: "text-amber-400"
|
||||||
|
: isLight
|
||||||
|
? "text-emerald-700"
|
||||||
|
: "text-emerald-400/90",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{toast}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => void saveChanges()}
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-xl bg-indigo-600 px-5 py-2.5 text-sm font-bold text-white shadow-lg shadow-indigo-900/20 hover:bg-indigo-700 disabled:opacity-50 sm:min-w-[10rem]"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Save changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!activeStoreId ? (
|
{!activeStoreId ? (
|
||||||
@ -854,69 +895,6 @@ export default function JunoEngineModulesPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"text-xs mt-6",
|
|
||||||
isLight ? "text-slate-500" : "text-slate-500",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Widget appearance:{" "}
|
|
||||||
<Link
|
|
||||||
href="/dashboard/juno-engine/storefront-chat"
|
|
||||||
className="font-semibold text-indigo-500 hover:underline"
|
|
||||||
>
|
|
||||||
Storefront Chat Inbox
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"fixed bottom-0 left-0 right-0 md:left-72 z-[60] border-t px-4 py-3 md:px-8",
|
|
||||||
isLight
|
|
||||||
? "border-slate-200 bg-white/95 backdrop-blur-md"
|
|
||||||
: "border-white/10 bg-slate-950/95 backdrop-blur-md",
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
paddingBottom: "max(0.75rem, env(safe-area-inset-bottom))",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="max-w-6xl mx-auto flex items-center justify-between gap-3">
|
|
||||||
{toast ? (
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"text-xs flex-1 min-w-0",
|
|
||||||
toast.includes("fail") || toast.includes("Failed")
|
|
||||||
? isLight
|
|
||||||
? "text-amber-700"
|
|
||||||
: "text-amber-400"
|
|
||||||
: isLight
|
|
||||||
? "text-emerald-700"
|
|
||||||
: "text-emerald-400/90",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{toast}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-slate-500 hidden sm:inline">
|
|
||||||
Changes are local until you save.
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={saving}
|
|
||||||
onClick={() => void saveChanges()}
|
|
||||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-bold disabled:opacity-50 shrink-0 shadow-lg shadow-indigo-900/20"
|
|
||||||
>
|
|
||||||
{saving ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
Save changes
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{modal}
|
{modal}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -40,9 +40,15 @@ export default function StorefrontChatWidgetPage() {
|
|||||||
const [otpBusy, setOtpBusy] = useState(false)
|
const [otpBusy, setOtpBusy] = useState(false)
|
||||||
const [otpMessage, setOtpMessage] = useState<string | null>(null)
|
const [otpMessage, setOtpMessage] = useState<string | null>(null)
|
||||||
const [sessionUntil, setSessionUntil] = useState<string | null>(null)
|
const [sessionUntil, setSessionUntil] = useState<string | null>(null)
|
||||||
|
/** collect_email → enter_code (after send) → verified */
|
||||||
|
const [otpPhase, setOtpPhase] = useState<"collect_email" | "enter_code" | "verified">(
|
||||||
|
"collect_email",
|
||||||
|
)
|
||||||
|
|
||||||
const composerRef = useRef<HTMLTextAreaElement | null>(null)
|
const composerRef = useRef<HTMLTextAreaElement | null>(null)
|
||||||
const messagesEndRef = useRef<HTMLDivElement | null>(null)
|
const messagesEndRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const messagesScrollRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const otpInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// This page is rendered inside a storefront iframe. Force a neutral
|
// This page is rendered inside a storefront iframe. Force a neutral
|
||||||
@ -126,9 +132,20 @@ export default function StorefrontChatWidgetPage() {
|
|||||||
}, [shop, visitorId])
|
}, [shop, visitorId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
const el = messagesScrollRef.current
|
||||||
|
if (el) {
|
||||||
|
el.scrollTop = el.scrollHeight
|
||||||
|
} else {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||||
|
}
|
||||||
}, [messages])
|
}, [messages])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (otpPhase === "enter_code") {
|
||||||
|
setTimeout(() => otpInputRef.current?.focus(), 0)
|
||||||
|
}
|
||||||
|
}, [otpPhase])
|
||||||
|
|
||||||
const sendOtp = async () => {
|
const sendOtp = async () => {
|
||||||
if (!shop || !visitorId) return
|
if (!shop || !visitorId) return
|
||||||
const email = verifyEmail.trim()
|
const email = verifyEmail.trim()
|
||||||
@ -148,7 +165,9 @@ export default function StorefrontChatWidgetPage() {
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(data?.message || "Could not send code")
|
throw new Error(data?.message || "Could not send code")
|
||||||
}
|
}
|
||||||
setOtpMessage("Check your inbox for a 6-digit code.")
|
setOtpCode("")
|
||||||
|
setOtpPhase("enter_code")
|
||||||
|
setOtpMessage(null)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setOtpMessage(e instanceof Error ? e.message : "Failed to send code")
|
setOtpMessage(e instanceof Error ? e.message : "Failed to send code")
|
||||||
} finally {
|
} finally {
|
||||||
@ -156,6 +175,12 @@ export default function StorefrontChatWidgetPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const backToEmailStep = () => {
|
||||||
|
setOtpPhase("collect_email")
|
||||||
|
setOtpCode("")
|
||||||
|
setOtpMessage(null)
|
||||||
|
}
|
||||||
|
|
||||||
const verifyOtp = async () => {
|
const verifyOtp = async () => {
|
||||||
if (!shop || !visitorId) return
|
if (!shop || !visitorId) return
|
||||||
const email = verifyEmail.trim()
|
const email = verifyEmail.trim()
|
||||||
@ -180,7 +205,10 @@ export default function StorefrontChatWidgetPage() {
|
|||||||
typeof data.sessionExpiresAt === "string" ? data.sessionExpiresAt : null,
|
typeof data.sessionExpiresAt === "string" ? data.sessionExpiresAt : null,
|
||||||
)
|
)
|
||||||
setOtpCode("")
|
setOtpCode("")
|
||||||
setOtpMessage("Verified. You can ask about your orders for about one hour.")
|
setOtpPhase("verified")
|
||||||
|
setOtpMessage(
|
||||||
|
"You’re verified. You can ask about your orders for about one hour. We’ll use this session until it expires.",
|
||||||
|
)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setOtpMessage(e instanceof Error ? e.message : "Verification failed")
|
setOtpMessage(e instanceof Error ? e.message : "Verification failed")
|
||||||
} finally {
|
} finally {
|
||||||
@ -244,8 +272,11 @@ export default function StorefrontChatWidgetPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-screen h-screen overflow-hidden bg-white text-slate-900 flex flex-col">
|
<div className="flex h-screen w-screen min-h-0 flex-col overflow-hidden bg-white text-slate-900">
|
||||||
<div className="flex-1 overflow-y-auto p-4 bg-gradient-to-b from-white to-slate-50">
|
<div
|
||||||
|
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]"
|
||||||
|
>
|
||||||
{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">
|
||||||
{sendError}
|
{sendError}
|
||||||
@ -288,62 +319,110 @@ export default function StorefrontChatWidgetPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-3 border-t border-slate-200 bg-white flex-shrink-0 space-y-3">
|
<div className="flex-shrink-0 space-y-3 border-t border-slate-200 bg-white p-3">
|
||||||
<details className="rounded-xl border border-slate-200 bg-slate-50/80 px-3 py-2">
|
<details className="rounded-xl border border-slate-200 bg-slate-50/80 px-3 py-2">
|
||||||
<summary className="cursor-pointer text-xs font-bold text-slate-700">
|
<summary className="cursor-pointer text-xs font-bold text-slate-700">
|
||||||
Order help — verify your email
|
Order help — verify your email
|
||||||
</summary>
|
</summary>
|
||||||
<div className="mt-2 space-y-2">
|
<div className="mt-2 space-y-3">
|
||||||
<p className="text-[11px] text-slate-500">
|
{otpPhase === "verified" ? (
|
||||||
For order status and account-specific questions, enter the email you used
|
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2">
|
||||||
at checkout. We'll send a one-time code.
|
<p className="text-xs font-bold text-emerald-900">Email verified</p>
|
||||||
</p>
|
<p className="mt-1 text-[11px] text-emerald-800">{otpMessage}</p>
|
||||||
<input
|
{sessionUntil ? (
|
||||||
type="email"
|
<p className="mt-2 text-[10px] text-emerald-700/90">
|
||||||
value={verifyEmail}
|
Session active until{" "}
|
||||||
onChange={(e) => setVerifyEmail(e.target.value)}
|
<span className="font-semibold">
|
||||||
placeholder="you@example.com"
|
{new Date(sessionUntil).toLocaleString()}
|
||||||
className="w-full border border-slate-200 rounded-lg px-2 py-1.5 text-sm"
|
</span>
|
||||||
/>
|
</p>
|
||||||
<div className="flex gap-2">
|
) : null}
|
||||||
<button
|
</div>
|
||||||
type="button"
|
) : (
|
||||||
onClick={() => void sendOtp()}
|
<>
|
||||||
disabled={otpSending}
|
{otpPhase === "collect_email" ? (
|
||||||
className="text-xs font-bold px-3 py-1.5 rounded-lg bg-slate-800 text-white disabled:opacity-50"
|
<>
|
||||||
>
|
<p className="text-[11px] text-slate-500">
|
||||||
{otpSending ? "Sending…" : "Send code"}
|
For order status and account-specific questions, enter the
|
||||||
</button>
|
email you used at checkout. We'll send a one-time code.
|
||||||
</div>
|
</p>
|
||||||
<div className="flex gap-2 items-center">
|
<input
|
||||||
<input
|
type="email"
|
||||||
type="text"
|
value={verifyEmail}
|
||||||
inputMode="numeric"
|
onChange={(e) => setVerifyEmail(e.target.value)}
|
||||||
maxLength={6}
|
placeholder="you@example.com"
|
||||||
value={otpCode}
|
autoComplete="email"
|
||||||
onChange={(e) =>
|
className="w-full rounded-lg border border-slate-200 px-2 py-1.5 text-sm"
|
||||||
setOtpCode(e.target.value.replace(/\D/g, "").slice(0, 6))
|
/>
|
||||||
}
|
<button
|
||||||
placeholder="000000"
|
type="button"
|
||||||
className="w-28 border border-slate-200 rounded-lg px-2 py-1.5 text-sm tracking-widest"
|
onClick={() => void sendOtp()}
|
||||||
/>
|
disabled={otpSending}
|
||||||
<button
|
className="w-full rounded-lg bg-slate-800 px-3 py-2 text-xs font-bold text-white disabled:opacity-50 sm:w-auto"
|
||||||
type="button"
|
>
|
||||||
onClick={() => void verifyOtp()}
|
{otpSending ? "Sending…" : "Send code"}
|
||||||
disabled={otpBusy}
|
</button>
|
||||||
className="text-xs font-bold px-3 py-1.5 rounded-lg bg-indigo-600 text-white disabled:opacity-50"
|
</>
|
||||||
>
|
) : (
|
||||||
{otpBusy ? "…" : "Verify"}
|
<>
|
||||||
</button>
|
<div
|
||||||
</div>
|
role="status"
|
||||||
{otpMessage ? (
|
className="rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-2 text-[11px] text-indigo-900"
|
||||||
<p className="text-[11px] text-slate-600">{otpMessage}</p>
|
>
|
||||||
) : null}
|
<p className="font-bold">Code sent</p>
|
||||||
{sessionUntil ? (
|
<p className="mt-1 text-indigo-800">
|
||||||
<p className="text-[10px] text-slate-400">
|
We emailed a 6-digit code to{" "}
|
||||||
Session active until {new Date(sessionUntil).toLocaleString()}
|
<span className="font-semibold break-all">
|
||||||
</p>
|
{verifyEmail.trim()}
|
||||||
) : null}
|
</span>
|
||||||
|
. Enter it below (check spam).
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={backToEmailStep}
|
||||||
|
className="mt-2 text-[11px] font-bold text-indigo-700 underline decoration-indigo-300 underline-offset-2 hover:text-indigo-900"
|
||||||
|
>
|
||||||
|
Wrong email? Start over
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<label className="sr-only">Verification code</label>
|
||||||
|
<input
|
||||||
|
ref={otpInputRef}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
maxLength={6}
|
||||||
|
value={otpCode}
|
||||||
|
onChange={(e) =>
|
||||||
|
setOtpCode(
|
||||||
|
e.target.value.replace(/\D/g, "").slice(0, 6),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="000000"
|
||||||
|
className="w-32 rounded-lg border border-slate-200 px-2 py-2 text-sm font-semibold tracking-[0.2em]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void verifyOtp()}
|
||||||
|
disabled={otpBusy}
|
||||||
|
className="rounded-lg bg-indigo-600 px-4 py-2 text-xs font-bold text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{otpBusy ? "Verifying…" : "Verify"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{otpMessage ? (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="rounded-md border border-amber-200 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-900"
|
||||||
|
>
|
||||||
|
{otpMessage}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user