diff --git a/src/app/dashboard/customers/page.tsx b/src/app/dashboard/customers/page.tsx index 3ad6b8c..e14b11d 100644 --- a/src/app/dashboard/customers/page.tsx +++ b/src/app/dashboard/customers/page.tsx @@ -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([]) const [loading, setLoading] = useState(true) const [error, setError] = useState("") const [searchTerm, setSearchTerm] = useState("") + const [openMenuCustomerId, setOpenMenuCustomerId] = useState(null) + const [menuRect, setMenuRect] = useState<{ top: number; left: number } | null>(null) + const [storeDomain, setStoreDomain] = useState(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,16 +171,9 @@ export default function CustomersPage() { return (
-
-
-

Customers

-

View and manage your Shopify customer base.

-
-
- -
+
+

Customers

+

View and manage your Shopify customer base.

@@ -180,8 +260,23 @@ export default function CustomersPage() { {new Date(customer.created_at).toLocaleDateString()} - @@ -192,6 +287,75 @@ export default function CustomersPage() {
+ + {mounted && + openMenuCustomerId !== null && + menuRect !== null && + createPortal( + <> +
+
+ {(() => { + 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 ( + <> + + + + + ) + })()} +
+ , + document.body, + )}
) } diff --git a/src/app/dashboard/juno-engine/layout.tsx b/src/app/dashboard/juno-engine/layout.tsx index 424b351..ac3d124 100644 --- a/src/app/dashboard/juno-engine/layout.tsx +++ b/src/app/dashboard/juno-engine/layout.tsx @@ -52,15 +52,15 @@ export default function JunoEngineLayout({ ] return ( -
-
+
+

JUNO Engine

Manage your communication and support infrastructure.

-
+
{tabs.map((tab) => { const isActive = pathname === tab.href const Icon = tab.icon @@ -80,9 +80,7 @@ export default function JunoEngineLayout({ })}
-
- {children} -
+
{children}
) } diff --git a/src/app/dashboard/juno-engine/modules/page.tsx b/src/app/dashboard/juno-engine/modules/page.tsx index 73ba249..24f8244 100644 --- a/src/app/dashboard/juno-engine/modules/page.tsx +++ b/src/app/dashboard/juno-engine/modules/page.tsx @@ -728,9 +728,9 @@ export default function JunoEngineModulesPage() { ) return ( -
-
-
+
+
+

@@ -756,7 +756,48 @@ export default function JunoEngineModulesPage() { .

+

+ Changes are local until you save. +

+ {activeStoreId && !loading ? ( +
+ {toast ? ( +

+ {toast} +

+ ) : null} + +
+ ) : null}
{!activeStoreId ? ( @@ -854,69 +895,6 @@ export default function JunoEngineModulesPage() { })}
-

- Widget appearance:{" "} - - Storefront Chat Inbox - -

- -
-
- {toast ? ( -

- {toast} -

- ) : ( - - Changes are local until you save. - - )} - -
-
- {modal} )} diff --git a/src/app/storefront-chat/widget/page.tsx b/src/app/storefront-chat/widget/page.tsx index 2b8b07f..725a0ec 100644 --- a/src/app/storefront-chat/widget/page.tsx +++ b/src/app/storefront-chat/widget/page.tsx @@ -40,9 +40,15 @@ export default function StorefrontChatWidgetPage() { const [otpBusy, setOtpBusy] = useState(false) const [otpMessage, setOtpMessage] = useState(null) const [sessionUntil, setSessionUntil] = useState(null) + /** collect_email → enter_code (after send) → verified */ + const [otpPhase, setOtpPhase] = useState<"collect_email" | "enter_code" | "verified">( + "collect_email", + ) const composerRef = useRef(null) const messagesEndRef = useRef(null) + const messagesScrollRef = useRef(null) + const otpInputRef = useRef(null) useEffect(() => { // This page is rendered inside a storefront iframe. Force a neutral @@ -126,9 +132,20 @@ export default function StorefrontChatWidgetPage() { }, [shop, visitorId]) useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) + 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 ( -
-
+
+
{sendError ? (
{sendError} @@ -288,62 +319,110 @@ export default function StorefrontChatWidgetPage() { )}
-
+
Order help — verify your email -
-

- For order status and account-specific questions, enter the email you used - at checkout. We'll send a one-time code. -

- setVerifyEmail(e.target.value)} - placeholder="you@example.com" - className="w-full border border-slate-200 rounded-lg px-2 py-1.5 text-sm" - /> -
- -
-
- - 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" - /> - -
- {otpMessage ? ( -

{otpMessage}

- ) : null} - {sessionUntil ? ( -

- Session active until {new Date(sessionUntil).toLocaleString()} -

- ) : null} +
+ {otpPhase === "verified" ? ( +
+

Email verified

+

{otpMessage}

+ {sessionUntil ? ( +

+ Session active until{" "} + + {new Date(sessionUntil).toLocaleString()} + +

+ ) : null} +
+ ) : ( + <> + {otpPhase === "collect_email" ? ( + <> +

+ For order status and account-specific questions, enter the + email you used at checkout. We'll send a one-time code. +

+ setVerifyEmail(e.target.value)} + placeholder="you@example.com" + autoComplete="email" + className="w-full rounded-lg border border-slate-200 px-2 py-1.5 text-sm" + /> + + + ) : ( + <> +
+

Code sent

+

+ We emailed a 6-digit code to{" "} + + {verifyEmail.trim()} + + . Enter it below (check spam). +

+ +
+
+ + + 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]" + /> + +
+ + )} + {otpMessage ? ( +

+ {otpMessage} +

+ ) : null} + + )}