@mohasinac/appkit 4.7.1 → 4.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_internal/client/features/layout/DashboardLayoutClient.js +25 -5
- package/dist/features/auth/checkout-value-otp.d.ts +7 -1
- package/dist/features/auth/checkout-value-otp.js +9 -2
- package/dist/features/cart/components/CartDrawer.js +2 -2
- package/dist/features/checkout/actions/checkout-value-otp-actions.js +12 -1
- package/package.json +1 -1
|
@@ -22,8 +22,9 @@ import { normalizeError } from "../../../../errors/normalize";
|
|
|
22
22
|
* the underlying *Sidebar components.
|
|
23
23
|
*/
|
|
24
24
|
import { useCallback, useEffect, useState, startTransition } from "react";
|
|
25
|
-
import { usePathname } from "next/navigation";
|
|
25
|
+
import { usePathname, useRouter } from "next/navigation";
|
|
26
26
|
import Link from "next/link";
|
|
27
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
27
28
|
import { useDashboardNav } from "../../../../features/layout/DashboardNavContext";
|
|
28
29
|
import { AdminSidebar } from "../../../../features/admin/components/AdminSidebar";
|
|
29
30
|
import { StoreSidebar } from "../../../../features/seller/components/SellerSidebar";
|
|
@@ -33,7 +34,10 @@ import { filterNavItems } from "./filterNavItems";
|
|
|
33
34
|
import { useSiteSettings } from "../../../../core/hooks/useSiteSettings";
|
|
34
35
|
import { useTheme } from "../../theme";
|
|
35
36
|
import { BackgroundRenderer, Div, Nav, Span } from "../../../../ui";
|
|
37
|
+
import { useToast } from "../../../../ui/components/Toast";
|
|
36
38
|
import { useVisualViewportInset } from "../../../../react/hooks/useVisualViewportInset";
|
|
39
|
+
import { useSession } from "../../../../react/contexts/SessionContext";
|
|
40
|
+
import { ROUTES } from "../../../../next/routing/route-map";
|
|
37
41
|
/**
|
|
38
42
|
* Hoisted drawer-state hook — the matchMedia-aware open/close logic that was
|
|
39
43
|
* triplicated across admin/store/user layouts. Used internally by
|
|
@@ -153,6 +157,24 @@ export function DashboardLayoutClient({ variant, groups, permissions, activeHref
|
|
|
153
157
|
const storageKey = `appkit:sidebar-open:${variant}`;
|
|
154
158
|
const { desktopOpen, mobileOpen, close, closeMobile, toggle } = useResponsiveDrawer(storageKey);
|
|
155
159
|
useEffect(() => { closeMobile(); }, [pathname, closeMobile]);
|
|
160
|
+
const router = useRouter();
|
|
161
|
+
const { signOut } = useSession();
|
|
162
|
+
const { showToast } = useToast();
|
|
163
|
+
const queryClient = useQueryClient();
|
|
164
|
+
const handleLogout = useCallback(async () => {
|
|
165
|
+
try {
|
|
166
|
+
await signOut();
|
|
167
|
+
void queryClient.invalidateQueries({ queryKey: ["wishlist"] });
|
|
168
|
+
void queryClient.invalidateQueries({ queryKey: ["cart"] });
|
|
169
|
+
void queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
|
170
|
+
showToast("Signed out successfully", "info");
|
|
171
|
+
}
|
|
172
|
+
catch (_err) {
|
|
173
|
+
void normalizeError(_err);
|
|
174
|
+
showToast("Signed out", "info");
|
|
175
|
+
}
|
|
176
|
+
router.push(String(ROUTES.AUTH.LOGIN));
|
|
177
|
+
}, [signOut, queryClient, router, showToast]);
|
|
156
178
|
const { data: settings } = useSiteSettings();
|
|
157
179
|
const navConfig = settings?.navConfig;
|
|
158
180
|
const { theme } = useTheme();
|
|
@@ -166,10 +188,8 @@ export function DashboardLayoutClient({ variant, groups, permissions, activeHref
|
|
|
166
188
|
? filteredGroups
|
|
167
189
|
: groups;
|
|
168
190
|
const crossNavLinkClass = "flex items-center gap-[var(--appkit-space-2)] rounded-lg px-[var(--appkit-space-3)] py-[var(--appkit-space-2)] text-[0.8125rem] font-medium text-[var(--appkit-color-text-muted)] hover:bg-[var(--appkit-color-surface-elevated)]/60 hover:text-[var(--appkit-color-text)] transition-colors";
|
|
169
|
-
const
|
|
170
|
-
const renderCrossNavFooter =
|
|
171
|
-
? () => (_jsxs(Nav, { "aria-label": "Cross-dashboard links", padding: "y-xs", children: [crossNav?.storeHref && (_jsx(Link, { href: crossNav.storeHref, onClick: closeMobile, className: crossNavLinkClass, children: "Go to my Store" })), crossNav?.adminHref && (_jsx(Link, { href: crossNav.adminHref, onClick: closeMobile, className: crossNavLinkClass, children: "Back to Admin" })), crossNav?.profileHref && (_jsx(Link, { href: crossNav.profileHref, onClick: closeMobile, className: crossNavLinkClass, children: "My Profile" }))] }))
|
|
172
|
-
: undefined;
|
|
191
|
+
const logoutBtnClass = "flex w-full items-center gap-[var(--appkit-space-2)] rounded-lg px-[var(--appkit-space-3)] py-[var(--appkit-space-2)] text-[0.8125rem] font-medium text-error transition-colors hover:bg-error-surface hover:text-error";
|
|
192
|
+
const renderCrossNavFooter = () => (_jsxs(Nav, { "aria-label": "Cross-dashboard links", padding: "y-xs", children: [crossNav?.storeHref && (_jsx(Link, { href: crossNav.storeHref, onClick: closeMobile, className: crossNavLinkClass, children: "Go to my Store" })), crossNav?.adminHref && (_jsx(Link, { href: crossNav.adminHref, onClick: closeMobile, className: crossNavLinkClass, children: "Back to Admin" })), crossNav?.profileHref && (_jsx(Link, { href: crossNav.profileHref, onClick: closeMobile, className: crossNavLinkClass, children: "My Profile" })), _jsx("button", { type: "button", onClick: () => { closeMobile(); void handleLogout(); }, className: logoutBtnClass, children: "Log out" })] }));
|
|
173
193
|
return (_jsxs(_Fragment, { children: [hasBackground && (_jsx(BackgroundRenderer, { mode: theme === "dark" ? "dark" : "light", lightMode: resolvedLightBackground ?? { type: "color", value: "" }, darkMode: resolvedDarkBackground ?? { type: "color", value: "" } })), variant === "admin" && (_jsx(AdminSidebar, { variant: "sidebar", desktopOpen: desktopOpen, mobileOpen: mobileOpen, activePath: activeHref, groups: adminGroups, onCloseMobile: close, onToggle: toggle, className: className, renderFooter: renderCrossNavFooter })), variant === "store" && (_jsx(StoreSidebar, { variant: "sidebar", desktopOpen: desktopOpen, mobileOpen: mobileOpen, activeHref: activeHref, items: [], groups: filteredGroups, onCloseMobile: close, onToggle: toggle, className: className, renderFooter: renderCrossNavFooter })), variant === "user" && (_jsx(UserSidebar, { variant: "sidebar", desktopOpen: desktopOpen, mobileOpen: mobileOpen, items: filteredGroups.flatMap((g) => g.items), groups: filteredGroups, onCloseMobile: close, onToggle: toggle, className: className, renderFooter: renderCrossNavFooter })), _jsx(Div, { className: [
|
|
174
194
|
"w-full flex-1 flex flex-col min-h-[calc(100dvh-var(--header-height,3.5rem))]",
|
|
175
195
|
"pb-[var(--appkit-space-16)] lg:pb-[0]", // clears the fixed mobile bottom-nav
|
|
@@ -35,7 +35,13 @@ export interface CheckoutValueOtpDoc {
|
|
|
35
35
|
}
|
|
36
36
|
/**
|
|
37
37
|
* Enforce the per-user send rate-limit.
|
|
38
|
-
* Throws AuthorizationError
|
|
38
|
+
* Throws AuthorizationError with a user-facing "already sent, retry in N
|
|
39
|
+
* minutes" message when throttled — root-caused 2026-08-20: this used to
|
|
40
|
+
* throw the bare internal string "checkoutValueOtpRateLimit", which the
|
|
41
|
+
* client showed verbatim in a toast. A buyer who retried "Send code"
|
|
42
|
+
* within the cooldown (e.g. after not seeing the first email land) saw a
|
|
43
|
+
* cryptic error instead of being told a code was already on its way,
|
|
44
|
+
* making the whole feature look broken.
|
|
39
45
|
*/
|
|
40
46
|
export declare function enforceCheckoutValueOtpRateLimit(db: Db, uid: string): Promise<void>;
|
|
41
47
|
/** Build the checkout-value OTP WhatsApp/SMS message body. */
|
|
@@ -51,7 +51,13 @@ export function checkoutValueOtpRateLimitRef(db, uid) {
|
|
|
51
51
|
}
|
|
52
52
|
/**
|
|
53
53
|
* Enforce the per-user send rate-limit.
|
|
54
|
-
* Throws AuthorizationError
|
|
54
|
+
* Throws AuthorizationError with a user-facing "already sent, retry in N
|
|
55
|
+
* minutes" message when throttled — root-caused 2026-08-20: this used to
|
|
56
|
+
* throw the bare internal string "checkoutValueOtpRateLimit", which the
|
|
57
|
+
* client showed verbatim in a toast. A buyer who retried "Send code"
|
|
58
|
+
* within the cooldown (e.g. after not seeing the first email land) saw a
|
|
59
|
+
* cryptic error instead of being told a code was already on its way,
|
|
60
|
+
* making the whole feature look broken.
|
|
55
61
|
*/
|
|
56
62
|
export async function enforceCheckoutValueOtpRateLimit(db, uid) {
|
|
57
63
|
const metaRef = checkoutValueOtpRateLimitRef(db, uid);
|
|
@@ -63,7 +69,8 @@ export async function enforceCheckoutValueOtpRateLimit(db, uid) {
|
|
|
63
69
|
const lastSentMs = resolveDate(meta?.lastSentAt)?.getTime() ?? 0;
|
|
64
70
|
const elapsed = Date.now() - lastSentMs;
|
|
65
71
|
if (elapsed < CHECKOUT_VALUE_OTP_COOLDOWN_MS) {
|
|
66
|
-
|
|
72
|
+
const remainingMinutes = Math.max(1, Math.ceil((CHECKOUT_VALUE_OTP_COOLDOWN_MS - elapsed) / 60000));
|
|
73
|
+
throw new AuthorizationError(`We already sent a code — check your inbox (and spam folder). You can request a new one in ${remainingMinutes} minute${remainingMinutes === 1 ? "" : "s"}.`);
|
|
67
74
|
}
|
|
68
75
|
tx.set(metaRef, { lastSentAt: new Date() }, { merge: true });
|
|
69
76
|
});
|
|
@@ -12,8 +12,8 @@ const __O = {
|
|
|
12
12
|
};
|
|
13
13
|
const CLS_REMOVE_BTN = "self-start text-[var(--appkit-color-text-faint)] transition hover:text-error";
|
|
14
14
|
export function CartItemRow({ item, onQtyChange, onRemove, href, isOutOfStock = false }) {
|
|
15
|
-
return (_jsxs(Div, { layout: "flex", gap: "4", surface: "card", padding: "sm", className: `transition-opacity ${isOutOfStock ? "opacity-60" : ""}`, children: [_jsx(Div, { surface: "muted", className: `relative h-20 w-20 flex-shrink-0 ${__O.hidden}`, rounded: "lg", children: item.meta.image && (_jsx(MediaImage, { src: item.meta.image, alt: item.meta.title, size: "thumbnail" })) }), _jsxs(Stack, { justify: "between", className: "flex-1", children: [_jsxs(Row, { gap: "xs", className: "", align: "start", children: [href ? (_jsx(TextLink, { href: href, target: "_blank", rel: "noopener noreferrer", weight: "medium", className: "text-[var(--appkit-color-text)] hover:underline underline-offset-2 line-clamp-2", children: item.meta.title })) : (_jsx(Text, { truncate: 2, weight: "medium", color: "primary", children: item.meta.title })), isOutOfStock && (_jsx(Span, { padding: "pill-2xs", color: "error", surface: "danger-surface", weight: "semibold", className: "flex-shrink-0 text-[10px] tracking-wide", rounded: "default", transform: "uppercase", children: "Out of Stock" }))] }), item.meta.attributes &&
|
|
16
|
-
Object.keys(item.meta.attributes).length > 0 && (_jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "xs", children: Object.entries(item.meta.attributes)
|
|
15
|
+
return (_jsxs(Div, { layout: "flex", gap: "4", surface: "card", padding: "sm", className: `transition-opacity ${isOutOfStock ? "opacity-60" : ""}`, children: [_jsx(Div, { surface: "muted", className: `relative h-20 w-20 flex-shrink-0 ${__O.hidden}`, rounded: "lg", children: item.meta.image && (_jsx(MediaImage, { src: item.meta.image, alt: item.meta.title, size: "thumbnail" })) }), _jsxs(Stack, { justify: "between", className: "flex-1 min-w-0", children: [_jsxs(Row, { gap: "xs", className: "", align: "start", children: [href ? (_jsx(TextLink, { href: href, target: "_blank", rel: "noopener noreferrer", weight: "medium", className: "text-[var(--appkit-color-text)] hover:underline underline-offset-2 line-clamp-2", children: item.meta.title })) : (_jsx(Text, { truncate: 2, weight: "medium", color: "primary", children: item.meta.title })), isOutOfStock && (_jsx(Span, { padding: "pill-2xs", color: "error", surface: "danger-surface", weight: "semibold", className: "flex-shrink-0 text-[10px] tracking-wide", rounded: "default", transform: "uppercase", children: "Out of Stock" }))] }), item.meta.attributes &&
|
|
16
|
+
Object.keys(item.meta.attributes).length > 0 && (_jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "xs", truncate: 1, children: Object.entries(item.meta.attributes)
|
|
17
17
|
.map(([k, v]) => `${k}: ${v}`)
|
|
18
18
|
.join(", ") })), _jsxs(Row, { justify: "between", children: [_jsx(Text, { className: "text-[var(--appkit-color-text)]", weight: "semibold", children: formatCurrency(item.meta.price * item.quantity, item.meta.currency) }), onQtyChange && !isOutOfStock && (_jsxs(Row, { gap: "sm", children: [_jsx(Button, { rounded: "full", onClick: () => onQtyChange(item.id, item.quantity - 1), disabled: item.quantity <= 1, variant: "outline", size: "sm", className: "h-7 w-7 justify-center border border-neutral-200 border-[var(--appkit-color-border)] text-[length:var(--appkit-text-sm)] disabled:opacity-40", children: "\u2212" }), _jsx(Span, { size: "sm", className: "min-w-[1.5rem]", align: "center", children: item.quantity }), _jsx(Button, { rounded: "full", onClick: () => onQtyChange(item.id, item.quantity + 1), variant: "outline", size: "sm", className: "h-7 w-7 justify-center border border-neutral-200 border-[var(--appkit-color-border)] text-[length:var(--appkit-text-sm)]", children: "+" })] })), isOutOfStock && (_jsxs(Span, { size: "xs", color: "muted", children: ["Qty: ", item.quantity] }))] })] }), onRemove && (_jsx(Button, { onClick: () => onRemove(item.id), variant: "ghost", size: "sm", "aria-label": ACTIONS.CART["remove-item"].ariaLabel, className: CLS_REMOVE_BTN, children: "\u2715" }))] }));
|
|
19
19
|
}
|
|
@@ -32,7 +32,7 @@ export async function sendCheckoutValueOtp(userId, userEmail) {
|
|
|
32
32
|
const maskedEmail = domain
|
|
33
33
|
? `${local.length <= 2 ? "*".repeat(local.length) : local[0] + "*".repeat(local.length - 2) + local[local.length - 1]}@${domain}`
|
|
34
34
|
: "***";
|
|
35
|
-
await sendEmail({
|
|
35
|
+
const { error } = await sendEmail({
|
|
36
36
|
to: userEmail,
|
|
37
37
|
subject: `${siteName}: Verify your order`,
|
|
38
38
|
html: `
|
|
@@ -44,6 +44,17 @@ export async function sendCheckoutValueOtp(userId, userEmail) {
|
|
|
44
44
|
</div>
|
|
45
45
|
`,
|
|
46
46
|
});
|
|
47
|
+
if (error) {
|
|
48
|
+
// Root-caused 2026-08-20: this used to be fire-and-forget — the
|
|
49
|
+
// Firestore OTP doc was written and the caller always got back a
|
|
50
|
+
// success response ("code sent") regardless of whether the email
|
|
51
|
+
// actually went out. Any Resend-side failure (bad API key,
|
|
52
|
+
// unverified sending domain, outage) silently stranded the buyer
|
|
53
|
+
// with a checkout they could never complete, since the OTP gate
|
|
54
|
+
// gave no indication anything had gone wrong.
|
|
55
|
+
serverLogger.error("Failed to send checkout value OTP email", { uid: userId, error });
|
|
56
|
+
throw new ValidationError("We couldn't send the verification code — please try again in a moment, or contact support if this keeps happening.");
|
|
57
|
+
}
|
|
47
58
|
serverLogger.info(`Checkout value OTP sent: uid=${userId}`);
|
|
48
59
|
return { maskedEmail };
|
|
49
60
|
}
|