fix: UI fixes for Modules, Customer, Storefront

This commit is contained in:
talatops 2026-04-12 12:11:13 +05:00
parent 2235ab6770
commit 61b4db37c0
4 changed files with 364 additions and 145 deletions

View File

@ -1,16 +1,21 @@
"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 {
Users,
Search,
MoreHorizontal,
Mail,
Plus,
Loader2,
AlertCircle,
MapPin
MapPin,
ExternalLink,
Copy,
} from "lucide-react"
import { useTheme } from "@/components/ThemeProvider"
import { cn } from "@/lib/utils"
interface ShopifyCustomer {
id: number
@ -45,11 +50,93 @@ function formatCustomerLocation(default_address: ShopifyCustomer["default_addres
return "Not provided"
}
const MENU_WIDTH_PX = 200
export default function CustomersPage() {
const { theme } = useTheme()
const isLight = theme === "light"
const [customers, setCustomers] = useState<ShopifyCustomer[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = 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(() => {
const fetchCustomers = async () => {
@ -84,17 +171,10 @@ export default function CustomersPage() {
return (
<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>
<div className="flex-shrink-0">
<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>
</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 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="p-4 md:p-6 border-b border-white/10 flex flex-col sm:flex-row gap-3 md:gap-4 justify-between bg-white/5 flex-shrink-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>
</td>
<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">
<MoreHorizontal className="w-4 h-4 md:w-5 md:h-5" />
<button
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>
</td>
</tr>
@ -192,6 +287,75 @@ export default function CustomersPage() {
</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>
)
}

View File

@ -52,15 +52,15 @@ export default function JunoEngineLayout({
]
return (
<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 w-full flex-col gap-4 md:gap-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 shrink-0">
<div>
<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>
</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) => {
const isActive = pathname === tab.href
const Icon = tab.icon
@ -80,9 +80,7 @@ export default function JunoEngineLayout({
})}
</div>
<div className="flex-1 overflow-hidden">
{children}
</div>
<div className="min-h-0 w-full flex-1">{children}</div>
</div>
)
}

View File

@ -728,9 +728,9 @@ export default function JunoEngineModulesPage() {
)
return (
<div className="flex flex-col min-h-0 flex-1 pb-28">
<div className="flex-shrink-0 space-y-5 mb-6">
<div>
<div className="flex w-full flex-col gap-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1 space-y-1">
<h1
className={cn(
"text-2xl md:text-3xl font-extrabold tracking-tight",
@ -741,7 +741,7 @@ export default function JunoEngineModulesPage() {
</h1>
<p
className={cn(
"text-sm mt-1 max-w-2xl",
"text-sm max-w-2xl",
isLight ? "text-slate-600" : "text-slate-400",
)}
>
@ -756,7 +756,48 @@ export default function JunoEngineModulesPage() {
</Link>
.
</p>
<p
className={cn(
"text-xs pt-1",
isLight ? "text-slate-500" : "text-slate-500",
)}
>
Changes are local until you save.
</p>
</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>
{!activeStoreId ? (
@ -854,69 +895,6 @@ export default function JunoEngineModulesPage() {
})}
</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}
</>
)}

View File

@ -40,9 +40,15 @@ export default function StorefrontChatWidgetPage() {
const [otpBusy, setOtpBusy] = useState(false)
const [otpMessage, setOtpMessage] = 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 messagesEndRef = useRef<HTMLDivElement | null>(null)
const messagesScrollRef = useRef<HTMLDivElement | null>(null)
const otpInputRef = useRef<HTMLInputElement | null>(null)
useEffect(() => {
// This page is rendered inside a storefront iframe. Force a neutral
@ -126,9 +132,20 @@ export default function StorefrontChatWidgetPage() {
}, [shop, visitorId])
useEffect(() => {
const el = messagesScrollRef.current
if (el) {
el.scrollTop = el.scrollHeight
} else {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}
}, [messages])
useEffect(() => {
if (otpPhase === "enter_code") {
setTimeout(() => otpInputRef.current?.focus(), 0)
}
}, [otpPhase])
const sendOtp = async () => {
if (!shop || !visitorId) return
const email = verifyEmail.trim()
@ -148,7 +165,9 @@ export default function StorefrontChatWidgetPage() {
if (!res.ok) {
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) {
setOtpMessage(e instanceof Error ? e.message : "Failed to send code")
} finally {
@ -156,6 +175,12 @@ export default function StorefrontChatWidgetPage() {
}
}
const backToEmailStep = () => {
setOtpPhase("collect_email")
setOtpCode("")
setOtpMessage(null)
}
const verifyOtp = async () => {
if (!shop || !visitorId) return
const email = verifyEmail.trim()
@ -180,7 +205,10 @@ export default function StorefrontChatWidgetPage() {
typeof data.sessionExpiresAt === "string" ? data.sessionExpiresAt : null,
)
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) {
setOtpMessage(e instanceof Error ? e.message : "Verification failed")
} finally {
@ -244,8 +272,11 @@ export default function StorefrontChatWidgetPage() {
}
return (
<div className="w-screen h-screen overflow-hidden bg-white text-slate-900 flex flex-col">
<div className="flex-1 overflow-y-auto p-4 bg-gradient-to-b from-white to-slate-50">
<div className="flex h-screen w-screen min-h-0 flex-col overflow-hidden bg-white text-slate-900">
<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 ? (
<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}
@ -288,62 +319,110 @@ export default function StorefrontChatWidgetPage() {
)}
</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">
<summary className="cursor-pointer text-xs font-bold text-slate-700">
Order help — verify your email
</summary>
<div className="mt-2 space-y-2">
<div className="mt-2 space-y-3">
{otpPhase === "verified" ? (
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2">
<p className="text-xs font-bold text-emerald-900">Email verified</p>
<p className="mt-1 text-[11px] text-emerald-800">{otpMessage}</p>
{sessionUntil ? (
<p className="mt-2 text-[10px] text-emerald-700/90">
Session active until{" "}
<span className="font-semibold">
{new Date(sessionUntil).toLocaleString()}
</span>
</p>
) : null}
</div>
) : (
<>
{otpPhase === "collect_email" ? (
<>
<p className="text-[11px] text-slate-500">
For order status and account-specific questions, enter the email you used
at checkout. We&apos;ll send a one-time code.
For order status and account-specific questions, enter the
email you used at checkout. We&apos;ll send a one-time code.
</p>
<input
type="email"
value={verifyEmail}
onChange={(e) => setVerifyEmail(e.target.value)}
placeholder="you@example.com"
className="w-full border border-slate-200 rounded-lg px-2 py-1.5 text-sm"
autoComplete="email"
className="w-full rounded-lg border border-slate-200 px-2 py-1.5 text-sm"
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => void sendOtp()}
disabled={otpSending}
className="text-xs font-bold px-3 py-1.5 rounded-lg bg-slate-800 text-white disabled:opacity-50"
className="w-full rounded-lg bg-slate-800 px-3 py-2 text-xs font-bold text-white disabled:opacity-50 sm:w-auto"
>
{otpSending ? "Sending…" : "Send code"}
</button>
</>
) : (
<>
<div
role="status"
className="rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-2 text-[11px] text-indigo-900"
>
<p className="font-bold">Code sent</p>
<p className="mt-1 text-indigo-800">
We emailed a 6-digit code to{" "}
<span className="font-semibold break-all">
{verifyEmail.trim()}
</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 gap-2 items-center">
<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))
setOtpCode(
e.target.value.replace(/\D/g, "").slice(0, 6),
)
}
placeholder="000000"
className="w-28 border border-slate-200 rounded-lg px-2 py-1.5 text-sm tracking-widest"
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="text-xs font-bold px-3 py-1.5 rounded-lg bg-indigo-600 text-white disabled:opacity-50"
className="rounded-lg bg-indigo-600 px-4 py-2 text-xs font-bold text-white disabled:opacity-50"
>
{otpBusy ? "…" : "Verify"}
{otpBusy ? "Verifying…" : "Verify"}
</button>
</div>
</>
)}
{otpMessage ? (
<p className="text-[11px] text-slate-600">{otpMessage}</p>
) : null}
{sessionUntil ? (
<p className="text-[10px] text-slate-400">
Session active until {new Date(sessionUntil).toLocaleString()}
<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>
</details>
<div className="flex items-end gap-2">