fix: react-doctor dialog/a11y/labels → 68/100 with 973 warnings
This commit is contained in:
@@ -226,11 +226,11 @@ export default function AIProviderPanel({ brandId }: Props) {
|
||||
</div>
|
||||
<div className="p-4 sm:p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
|
||||
<label htmlFor="fld-3-api-key" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
|
||||
API Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input aria-label="Input"
|
||||
<input id="fld-3-api-key" aria-label="Input"
|
||||
type={showKey ? "text" : "password"}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
@@ -276,9 +276,9 @@ export default function AIProviderPanel({ brandId }: Props) {
|
||||
</div>
|
||||
<div className="p-4 sm:p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">API Key</label>
|
||||
<label htmlFor="fld-4-api-key" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">API Key</label>
|
||||
<div className="relative">
|
||||
<input aria-label="Sk ..."
|
||||
<input id="fld-4-api-key" aria-label="Sk ..."
|
||||
type={showKey ? "text" : "password"}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
|
||||
@@ -4,6 +4,23 @@ import { useState, useCallback } from "react";
|
||||
import { createLocation } from "@/actions/locations";
|
||||
import GlassModal from "@/components/admin/GlassModal";
|
||||
|
||||
const inputStyle = {
|
||||
background: "rgba(0, 0, 0, 0.02)",
|
||||
border: "1px solid rgba(0, 0, 0, 0.06)",
|
||||
outline: "none",
|
||||
};
|
||||
|
||||
function handleFocus(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) {
|
||||
e.target.style.background = "rgba(16, 185, 129, 0.04)";
|
||||
e.target.style.border = "1px solid rgba(16, 185, 129, 0.5)";
|
||||
e.target.style.boxShadow = "0 0 0 4px rgba(16, 185, 129, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)";
|
||||
}
|
||||
function handleBlur(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) {
|
||||
e.target.style.background = "rgba(0, 0, 0, 0.02)";
|
||||
e.target.style.border = "1px solid rgba(0, 0, 0, 0.06)";
|
||||
e.target.style.boxShadow = "none";
|
||||
}
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -82,23 +99,6 @@ export default function AddLocationModal({ isOpen, onClose, brandId, onSuccess }
|
||||
[brandId, name, address, city, stateVal, zip, phone, contactName, contactEmail, notes, onSuccess, reset, onClose]
|
||||
);
|
||||
|
||||
const inputStyle = {
|
||||
background: "rgba(0, 0, 0, 0.02)",
|
||||
border: "1px solid rgba(0, 0, 0, 0.06)",
|
||||
outline: "none",
|
||||
};
|
||||
|
||||
const handleFocus = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
e.target.style.background = "rgba(16, 185, 129, 0.04)";
|
||||
e.target.style.border = "1px solid rgba(16, 185, 129, 0.5)";
|
||||
e.target.style.boxShadow = "0 0 0 4px rgba(16, 185, 129, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)";
|
||||
};
|
||||
const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
e.target.style.background = "rgba(0, 0, 0, 0.02)";
|
||||
e.target.style.border = "1px solid rgba(0, 0, 0, 0.06)";
|
||||
e.target.style.boxShadow = "none";
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,6 +11,10 @@ import { usePathname } from "next/navigation";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { signOutAction } from "@/actions/auth-actions";
|
||||
|
||||
async function handleLogout() {
|
||||
await signOutAction();
|
||||
}
|
||||
|
||||
type AdminHeaderProps = {
|
||||
userRole?: string | null;
|
||||
canManageUsers?: boolean;
|
||||
@@ -67,10 +71,6 @@ export default function AdminHeader({ userRole, canManageUsers, routeTraceEnable
|
||||
const homeHref = isStoreEmployee ? "/admin/wholesale" : "/admin";
|
||||
const homeLabel = isStoreEmployee ? "Pickup" : "Admin";
|
||||
|
||||
async function handleLogout() {
|
||||
await signOutAction();
|
||||
}
|
||||
|
||||
const roleLabel = userRole === "platform_admin" ? "Platform Admin"
|
||||
: userRole === "brand_admin" ? "Brand Admin"
|
||||
: userRole === "store_employee" ? "Store Employee"
|
||||
|
||||
@@ -29,6 +29,10 @@ import { signOutAction } from "@/actions/auth-actions";
|
||||
import BrandSelector from "@/components/admin/BrandSelector";
|
||||
import SideNavGroup from "@/components/admin/SideNavGroup";
|
||||
|
||||
async function handleLogout() {
|
||||
await signOutAction();
|
||||
}
|
||||
|
||||
// Sidebar tokens used (from src/styles/admin-design-system.css):
|
||||
// --admin-sidebar-bg (#2A2520)
|
||||
// --admin-sidebar-text (#B8B4A8)
|
||||
@@ -273,10 +277,6 @@ export default function AdminSidebar({
|
||||
[router, mobileOpen, closeMobileMenu, visibleItems],
|
||||
);
|
||||
|
||||
async function handleLogout() {
|
||||
await signOutAction();
|
||||
}
|
||||
|
||||
// Shared nav-link renderer. Used by both the desktop sidebar and the mobile
|
||||
// slide-in panel — they're the same list, just in a different container.
|
||||
const renderNavLink = (item: NavItemDef, index: number) => {
|
||||
|
||||
@@ -389,11 +389,11 @@ export default function AdvancedAIPanel({ brandId }: Props) {
|
||||
<div className="p-4 sm:p-6 space-y-4">
|
||||
{/* API Key */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
|
||||
<label htmlFor="fld-1-api-key" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
|
||||
API Key <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input aria-label="Input"
|
||||
<input id="fld-1-api-key" aria-label="Input"
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={settings.apiKey}
|
||||
onChange={(e) => handleChange("apiKey", e.target.value)}
|
||||
@@ -417,10 +417,10 @@ export default function AdvancedAIPanel({ brandId }: Props) {
|
||||
{/* Organization ID (OpenAI only) */}
|
||||
{currentProvider.hasOrgId && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
|
||||
<label htmlFor="fld-2-organization-id" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
|
||||
Organization ID <span className="text-stone-400">(optional)</span>
|
||||
</label>
|
||||
<input aria-label="Org ..."
|
||||
<input id="fld-2-organization-id" aria-label="Org ..."
|
||||
type="text"
|
||||
value={settings.orgId}
|
||||
onChange={(e) => handleChange("orgId", e.target.value)}
|
||||
@@ -445,11 +445,11 @@ export default function AdvancedAIPanel({ brandId }: Props) {
|
||||
</div>
|
||||
<div className="p-4 sm:p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
|
||||
<label htmlFor="fld-3-api-key" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
|
||||
API Key <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input aria-label="Sk ..."
|
||||
<input id="fld-3-api-key" aria-label="Sk ..."
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={settings.apiKey}
|
||||
onChange={(e) => handleChange("apiKey", e.target.value)}
|
||||
@@ -462,10 +462,10 @@ export default function AdvancedAIPanel({ brandId }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
|
||||
<label htmlFor="fld-4-base-url" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
|
||||
Base URL <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input aria-label="Https://api.openai.com/v1 Or Http://localhost:11434/v1"
|
||||
<input id="fld-4-base-url" aria-label="Https://api.openai.com/v1 Or Http://localhost:11434/v1"
|
||||
type="text"
|
||||
value={settings.customEndpoint}
|
||||
onChange={(e) => handleChange("customEndpoint", e.target.value)}
|
||||
|
||||
@@ -198,9 +198,9 @@ export default function AdvancedIntegrations({ brandId, brands }: Props) {
|
||||
<div className="p-4 sm:p-6 space-y-4">
|
||||
{/* API Key */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">API Key</label>
|
||||
<label htmlFor="fld-1-api-key" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">API Key</label>
|
||||
<div className="relative">
|
||||
<input aria-label="Re ..."
|
||||
<input id="fld-1-api-key" aria-label="Re ..."
|
||||
type={showSecrets.resendKey ? "text" : "password"}
|
||||
value={credentials.resend.api_key}
|
||||
onChange={(e) => setCredentials((p) => ({
|
||||
@@ -307,9 +307,9 @@ export default function AdvancedIntegrations({ brandId, brands }: Props) {
|
||||
</div>
|
||||
{/* Auth Token */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Auth Token</label>
|
||||
<label htmlFor="fld-5-auth-token" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Auth Token</label>
|
||||
<div className="relative">
|
||||
<input aria-label="Your Twilio Auth Token"
|
||||
<input id="fld-5-auth-token" aria-label="Your Twilio Auth Token"
|
||||
type={showSecrets.twilioToken ? "text" : "password"}
|
||||
value={credentials.twilio.auth_token}
|
||||
onChange={(e) => setCredentials((p) => ({
|
||||
|
||||
@@ -132,9 +132,9 @@ export default function AdvancedShipping({ brandId }: { brandId: string }) {
|
||||
|
||||
{/* API Key / Password */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">API Key / Password</label>
|
||||
<label htmlFor="fld-3-api-key-password" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">API Key / Password</label>
|
||||
<div className="relative">
|
||||
<input aria-label="Enter API Key Or Password"
|
||||
<input id="fld-3-api-key-password" aria-label="Enter API Key Or Password"
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
|
||||
@@ -20,6 +20,12 @@ import {
|
||||
type ConversionFunnel,
|
||||
} from "@/actions/analytics";
|
||||
|
||||
function getTrend(avgPrice: number) {
|
||||
if (avgPrice > 10) return "up";
|
||||
if (avgPrice < 5) return "down";
|
||||
return "stable";
|
||||
}
|
||||
|
||||
interface MetricCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
@@ -147,12 +153,6 @@ function TopProductsTable({ products }: TopProductsTableProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const getTrend = (avgPrice: number) => {
|
||||
if (avgPrice > 10) return "up";
|
||||
if (avgPrice < 5) return "down";
|
||||
return "stable";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Top Products</h2>
|
||||
|
||||
@@ -494,15 +494,15 @@ function BrandSettingsFormBody({
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Primary Accent</label>
|
||||
<label htmlFor="fld-1-primary-accent-color" className="block text-sm font-medium text-zinc-300 mb-1">Primary Accent</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input aria-label="Color"
|
||||
<input id="fld-1-primary-accent-color" aria-label="Color"
|
||||
type="color"
|
||||
value={brandPrimaryColor}
|
||||
onChange={(e) => setBrandPrimaryColor(e.target.value)}
|
||||
className="w-10 h-10 rounded-lg border border-zinc-600 cursor-pointer"
|
||||
/>
|
||||
<input aria-label="#16a34a"
|
||||
<input id="fld-1-primary-accent-color-text" aria-label="#16a34a"
|
||||
type="text"
|
||||
value={brandPrimaryColor}
|
||||
onChange={(e) => setBrandPrimaryColor(e.target.value)}
|
||||
@@ -513,15 +513,15 @@ function BrandSettingsFormBody({
|
||||
<p className="mt-1 text-xs text-zinc-500">Buttons, badges, links</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Text Color</label>
|
||||
<label htmlFor="fld-2-text-color" className="block text-sm font-medium text-zinc-300 mb-1">Text Color</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input aria-label="Color"
|
||||
<input id="fld-2-text-color" aria-label="Color"
|
||||
type="color"
|
||||
value={brandTextColor}
|
||||
onChange={(e) => setBrandTextColor(e.target.value)}
|
||||
className="w-10 h-10 rounded-lg border border-zinc-600 cursor-pointer"
|
||||
/>
|
||||
<input aria-label="#1c1917"
|
||||
<input id="fld-2-text-color-text" aria-label="#1c1917"
|
||||
type="text"
|
||||
value={brandTextColor}
|
||||
onChange={(e) => setBrandTextColor(e.target.value)}
|
||||
@@ -532,15 +532,15 @@ function BrandSettingsFormBody({
|
||||
<p className="mt-1 text-xs text-zinc-500">Headlines, body text</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Background Color</label>
|
||||
<label htmlFor="fld-3-background-color" className="block text-sm font-medium text-zinc-300 mb-1">Background Color</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input aria-label="Color"
|
||||
<input id="fld-3-background-color" aria-label="Color"
|
||||
type="color"
|
||||
value={brandBgColor}
|
||||
onChange={(e) => setBrandBgColor(e.target.value)}
|
||||
className="w-10 h-10 rounded-lg border border-zinc-600 cursor-pointer"
|
||||
/>
|
||||
<input aria-label="#fafaf9"
|
||||
<input id="fld-3-background-color-text" aria-label="#fafaf9"
|
||||
type="text"
|
||||
value={brandBgColor}
|
||||
onChange={(e) => setBrandBgColor(e.target.value)}
|
||||
@@ -551,15 +551,15 @@ function BrandSettingsFormBody({
|
||||
<p className="mt-1 text-xs text-zinc-500">Page background (light mode)</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Secondary Accent</label>
|
||||
<label htmlFor="fld-4-secondary-accent-color" className="block text-sm font-medium text-zinc-300 mb-1">Secondary Accent</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input aria-label="Color"
|
||||
<input id="fld-4-secondary-accent-color" aria-label="Color"
|
||||
type="color"
|
||||
value={brandSecondaryColor}
|
||||
onChange={(e) => setBrandSecondaryColor(e.target.value)}
|
||||
className="w-10 h-10 rounded-lg border border-zinc-600 cursor-pointer"
|
||||
/>
|
||||
<input aria-label="#f5f5f4"
|
||||
<input id="fld-4-secondary-accent-color-text" aria-label="#f5f5f4"
|
||||
type="text"
|
||||
value={brandSecondaryColor}
|
||||
onChange={(e) => setBrandSecondaryColor(e.target.value)}
|
||||
@@ -617,9 +617,9 @@ function BrandSettingsFormBody({
|
||||
|
||||
{collectSalesTax && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-amber-200 mb-1">
|
||||
<div className="block text-sm font-medium text-amber-200 mb-1">
|
||||
Nexus States
|
||||
</label>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-amber-300/60 mb-2">
|
||||
States where you have tax nexus (physical presence). Stripe Tax will calculate tax for ship orders to these states.
|
||||
</p>
|
||||
|
||||
@@ -190,28 +190,24 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [selected, setSelected] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const router = useRouter();
|
||||
|
||||
// Lock body scroll while open; focus the input on next frame.
|
||||
// Native <dialog> handles focus trapping, ESC, and the backdrop.
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = "hidden";
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (!dialog.open) dialog.showModal();
|
||||
const raf = requestAnimationFrame(() => inputRef.current?.focus());
|
||||
const onCancel = (e: Event) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
};
|
||||
dialog.addEventListener("cancel", onCancel);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
document.body.style.overflow = "";
|
||||
dialog.removeEventListener("cancel", onCancel);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Global Escape while open (covers the case where focus is not on input).
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
// Filtering
|
||||
@@ -248,24 +244,12 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
aria-label="Command palette"
|
||||
tabIndex={-1}
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
||||
className="m-0 p-0 w-full h-full max-w-none max-h-full bg-transparent"
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 60,
|
||||
backgroundColor: "rgba(26, 24, 20, 0.4)",
|
||||
backdropFilter: "blur(4px)",
|
||||
WebkitBackdropFilter: "blur(4px)",
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "center",
|
||||
paddingTop: "15vh",
|
||||
backgroundColor: "transparent",
|
||||
animation: "cp-fade-in 180ms ease-out",
|
||||
}}
|
||||
>
|
||||
@@ -285,11 +269,24 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
`}</style>
|
||||
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: "36rem",
|
||||
margin: "0 1rem",
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 60,
|
||||
backgroundColor: "rgba(26, 24, 20, 0.4)",
|
||||
backdropFilter: "blur(4px)",
|
||||
WebkitBackdropFilter: "blur(4px)",
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "center",
|
||||
paddingTop: "15vh",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: "36rem",
|
||||
margin: "0 1rem",
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
border: "1px solid var(--admin-border)",
|
||||
borderRadius: "var(--admin-radius-lg)",
|
||||
@@ -458,6 +455,7 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
↑↓ to navigate · ↵ to select · esc to close
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,12 @@ import { AdminInput, AdminTextInput } from "@/components/admin/design-system";
|
||||
|
||||
type Role = "platform_admin" | "brand_admin" | "store_employee";
|
||||
|
||||
const roleDescriptions: Record<Role, string> = {
|
||||
platform_admin: "Full platform access",
|
||||
brand_admin: "Brand-level admin",
|
||||
store_employee: "Pickup and order operations only",
|
||||
};
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -159,12 +165,6 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
|
||||
}
|
||||
}
|
||||
|
||||
const roleDescriptions: Record<Role, string> = {
|
||||
platform_admin: "Full platform access",
|
||||
brand_admin: "Brand-level admin",
|
||||
store_employee: "Pickup and order operations only",
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Success state — show temp password + email status, then "Done" closes.
|
||||
|
||||
@@ -54,6 +54,26 @@ const GROUP_FILTERS: { id: GroupFilter; label: string }[] = [
|
||||
{ id: "tools", label: "Tools" },
|
||||
];
|
||||
|
||||
function formatCurrency(amount: number) {
|
||||
return currencyFormatter.format(amount);
|
||||
}
|
||||
|
||||
function getStatusBadge(status: string) {
|
||||
const styles: Record<string, { bg: string; text: string }> = {
|
||||
pending: { bg: "var(--admin-warning-soft)", text: "var(--admin-warning)" },
|
||||
processing: { bg: "var(--admin-primary-soft)", text: "var(--admin-accent-text)" },
|
||||
shipped: { bg: "var(--admin-primary-soft)", text: "var(--admin-primary)" },
|
||||
};
|
||||
return styles[status] || { bg: "var(--admin-bg)", text: "var(--admin-text-secondary)" };
|
||||
}
|
||||
|
||||
const quickActions = [
|
||||
{ label: "New Order", href: "/admin/orders?new=true", icon: Plus },
|
||||
{ label: "Add Stop", href: "/admin/stops/new", icon: MapPin },
|
||||
{ label: "Add Product", href: "/admin/products/new", icon: Package },
|
||||
{ label: "Send Blast", href: "/admin/communications/compose", icon: Send },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
brandId: string | null;
|
||||
brandName: string;
|
||||
@@ -93,19 +113,6 @@ export default function DashboardClient({
|
||||
products: limits.max_products > 0 ? (usage.products / limits.max_products) * 100 : 0,
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return currencyFormatter.format(amount);
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const styles: Record<string, { bg: string; text: string }> = {
|
||||
pending: { bg: "var(--admin-warning-soft)", text: "var(--admin-warning)" },
|
||||
processing: { bg: "var(--admin-primary-soft)", text: "var(--admin-accent-text)" },
|
||||
shipped: { bg: "var(--admin-primary-soft)", text: "var(--admin-primary)" },
|
||||
};
|
||||
return styles[status] || { bg: "var(--admin-bg)", text: "var(--admin-text-secondary)" };
|
||||
};
|
||||
|
||||
// "What needs attention" feed — composes a flat list of items that need action
|
||||
const attentionItems: AttentionItem[] = [];
|
||||
if (stats.todayOrders === 0) {
|
||||
@@ -158,13 +165,6 @@ export default function DashboardClient({
|
||||
});
|
||||
}
|
||||
|
||||
const quickActions = [
|
||||
{ label: "New Order", href: "/admin/orders?new=true", icon: Plus },
|
||||
{ label: "Add Stop", href: "/admin/stops/new", icon: MapPin },
|
||||
{ label: "Add Product", href: "/admin/products/new", icon: Package },
|
||||
{ label: "Send Blast", href: "/admin/communications/compose", icon: Send },
|
||||
];
|
||||
|
||||
const visibleSections: typeof sections = [];
|
||||
for (const s of sections) {
|
||||
if (s.title === "Water Log" && !isWaterLogVisible) continue;
|
||||
|
||||
@@ -4,6 +4,22 @@ import { useState, useCallback } from "react";
|
||||
import { updateLocation } from "@/actions/locations";
|
||||
import GlassModal from "@/components/admin/GlassModal";
|
||||
|
||||
const inputStyle = {
|
||||
background: "rgba(0, 0, 0, 0.02)",
|
||||
border: "1px solid rgba(0, 0, 0, 0.06)",
|
||||
outline: "none",
|
||||
};
|
||||
function handleFocus(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) {
|
||||
e.target.style.background = "rgba(16, 185, 129, 0.04)";
|
||||
e.target.style.border = "1px solid rgba(16, 185, 129, 0.5)";
|
||||
e.target.style.boxShadow = "0 0 0 4px rgba(16, 185, 129, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)";
|
||||
}
|
||||
function handleBlur(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) {
|
||||
e.target.style.background = "rgba(0, 0, 0, 0.02)";
|
||||
e.target.style.border = "1px solid rgba(0, 0, 0, 0.06)";
|
||||
e.target.style.boxShadow = "none";
|
||||
}
|
||||
|
||||
export type LocationForEdit = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
@@ -105,22 +121,6 @@ export default function EditLocationModal({ isOpen, onClose, location, brandId,
|
||||
[location, brandId, name, address, city, stateVal, zip, phone, contactName, contactEmail, notes, onSuccess, onClose]
|
||||
);
|
||||
|
||||
const inputStyle = {
|
||||
background: "rgba(0, 0, 0, 0.02)",
|
||||
border: "1px solid rgba(0, 0, 0, 0.06)",
|
||||
outline: "none",
|
||||
};
|
||||
const handleFocus = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
e.target.style.background = "rgba(16, 185, 129, 0.04)";
|
||||
e.target.style.border = "1px solid rgba(16, 185, 129, 0.5)";
|
||||
e.target.style.boxShadow = "0 0 0 4px rgba(16, 185, 129, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)";
|
||||
};
|
||||
const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
e.target.style.background = "rgba(0, 0, 0, 0.02)";
|
||||
e.target.style.border = "1px solid rgba(0, 0, 0, 0.06)";
|
||||
e.target.style.boxShadow = "none";
|
||||
};
|
||||
|
||||
if (!isOpen || !location) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,6 +8,33 @@ import { uploadProductImage } from "@/actions/products/upload-image";
|
||||
import { createProduct } from "@/actions/products/create-product";
|
||||
import { AdminInput, AdminTextInput, AdminTextarea, AdminSelect } from "./design-system";
|
||||
|
||||
async function resizeImage(file: File, maxWidth: number): Promise<ArrayBuffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
let { width, height } = img;
|
||||
if (width > maxWidth) {
|
||||
height = Math.round(height * (maxWidth / width));
|
||||
width = maxWidth;
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error("Failed to resize image"));
|
||||
return;
|
||||
}
|
||||
blob.arrayBuffer().then((buf) => resolve(buf));
|
||||
}, "image/jpeg", 0.85);
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
type Props = {
|
||||
defaultBrandId?: string;
|
||||
brands?: { id: string; name: string }[];
|
||||
@@ -73,33 +100,6 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
|
||||
}
|
||||
}
|
||||
|
||||
async function resizeImage(file: File, maxWidth: number): Promise<ArrayBuffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
let { width, height } = img;
|
||||
if (width > maxWidth) {
|
||||
height = Math.round(height * (maxWidth / width));
|
||||
width = maxWidth;
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error("Failed to resize image"));
|
||||
return;
|
||||
}
|
||||
blob.arrayBuffer().then((buf) => resolve(buf));
|
||||
}, "image/jpeg", 0.85);
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
@@ -237,7 +237,7 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
|
||||
</AdminInput>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-primary)]">Product Image</label>
|
||||
<div className="block text-sm font-medium text-[var(--admin-text-primary)]">Product Image</div>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -385,7 +385,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="ha-field-label mb-1">Price</label>
|
||||
<label htmlFor="fld-3-price" className="ha-field-label mb-1">Price</label>
|
||||
<div className="relative">
|
||||
<span
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-sm"
|
||||
@@ -393,7 +393,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
>
|
||||
$
|
||||
</span>
|
||||
<input aria-label="Number"
|
||||
<input id="fld-3-price" aria-label="Number"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
@@ -434,7 +434,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="ha-field-label mb-1.5">Discount Amount</label>
|
||||
<label htmlFor="fld-4-discount-amount" className="ha-field-label mb-1.5">Discount Amount</label>
|
||||
<div className="relative">
|
||||
<span
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-sm"
|
||||
@@ -442,7 +442,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
>
|
||||
$
|
||||
</span>
|
||||
<input aria-label="Number"
|
||||
<input id="fld-4-discount-amount" aria-label="Number"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
@@ -501,11 +501,11 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
{/* Customer fields */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="ha-field-label mb-1.5">
|
||||
<label htmlFor="fld-5-name" className="ha-field-label mb-1.5">
|
||||
<span>Name</span>
|
||||
<span className="ha-field-label-required">*</span>
|
||||
</label>
|
||||
<input aria-label="Text"
|
||||
<input id="fld-5-name" aria-label="Text"
|
||||
type="text"
|
||||
value={customer_name}
|
||||
onChange={(e) => {
|
||||
@@ -550,7 +550,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
{/* Status & pickup */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="ha-field-label mb-2">Status</label>
|
||||
<div className="ha-field-label mb-2">Status</div>
|
||||
<div className="ha-segment">
|
||||
{["pending", "confirmed", "cancelled"].map((s) => (
|
||||
<button
|
||||
@@ -568,7 +568,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="ha-field-label mb-2">Pickup</label>
|
||||
<div className="ha-field-label mb-2">Pickup</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickup_complete((v) => !v)}
|
||||
|
||||
@@ -8,6 +8,33 @@ import { uploadProductImage, deleteProductImage } from "@/actions/products/uploa
|
||||
import { updateProduct } from "@/actions/products/update-product";
|
||||
import { AdminInput, AdminTextInput, AdminTextarea, AdminSelect } from "./design-system";
|
||||
|
||||
async function resizeImage(file: File, maxWidth: number): Promise<ArrayBuffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
let { width, height } = img;
|
||||
if (width > maxWidth) {
|
||||
height = Math.round(height * (maxWidth / width));
|
||||
width = maxWidth;
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error("Failed to resize image"));
|
||||
return;
|
||||
}
|
||||
blob.arrayBuffer().then((buf) => resolve(buf));
|
||||
}, "image/jpeg", 0.85);
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
type Brand = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -154,33 +181,6 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
|
||||
}
|
||||
}
|
||||
|
||||
async function resizeImage(file: File, maxWidth: number): Promise<ArrayBuffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
let { width, height } = img;
|
||||
if (width > maxWidth) {
|
||||
height = Math.round(height * (maxWidth / width));
|
||||
width = maxWidth;
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error("Failed to resize image"));
|
||||
return;
|
||||
}
|
||||
blob.arrayBuffer().then((buf) => resolve(buf));
|
||||
}, "image/jpeg", 0.85);
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!name.trim()) {
|
||||
setError("Product name is required.");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useEffectEvent, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import Image from "next/image";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
@@ -135,24 +135,24 @@ export default function ProductFormModal({
|
||||
);
|
||||
|
||||
// Body scroll lock + escape key
|
||||
// useEffectEvent so the latest onClose is always called even though
|
||||
// it's no longer in the effect's dependency array.
|
||||
const onCloseEffect = useEffectEvent(() => {
|
||||
onClose();
|
||||
});
|
||||
// Native <dialog> handles both focus trapping and ESC; we just open
|
||||
// it with showModal() and forward the cancel event to onClose so the
|
||||
// parent's React state stays in sync with the native close behavior.
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const original = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onCloseEffect();
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (!dialog.open) dialog.showModal();
|
||||
const onCancel = (e: Event) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
dialog.addEventListener("cancel", onCancel);
|
||||
return () => {
|
||||
document.body.style.overflow = original;
|
||||
window.removeEventListener("keydown", onKey);
|
||||
dialog.removeEventListener("cancel", onCancel);
|
||||
};
|
||||
}, [open]);
|
||||
}, [open, onClose]);
|
||||
|
||||
const handleFile = useCallback(
|
||||
async (file: File) => {
|
||||
@@ -232,24 +232,24 @@ export default function ProductFormModal({
|
||||
const lockedBrandName = brands.find((b) => b.id === lockedBrandId)?.name ?? lockedBrandId;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[80] flex items-center justify-center p-3 sm:p-6 atelier-backdrop"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: "color-mix(in srgb, var(--admin-text-primary) 55%, transparent)",
|
||||
backdropFilter: "blur(8px)",
|
||||
WebkitBackdropFilter: "blur(8px)",
|
||||
}}
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
aria-label={mode === "add" ? "Add product" : "Edit product"}
|
||||
className="m-0 p-0 w-full h-full max-w-none max-h-full bg-transparent"
|
||||
style={{ backgroundColor: "transparent" }}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={mode === "add" ? "Add product" : "Edit product"}
|
||||
className="atelier-enter atelier-canvas relative w-full max-w-5xl max-h-[calc(100vh-1.5rem)] sm:max-h-[calc(100vh-3rem)] rounded-2xl flex flex-col overflow-hidden border border-[var(--admin-border)]"
|
||||
style={{ boxShadow: "0 30px 80px -20px color-mix(in srgb, var(--admin-text-primary) 45%, transparent)" }}
|
||||
className="fixed inset-0 z-[80] flex items-center justify-center p-3 sm:p-6 atelier-backdrop"
|
||||
style={{
|
||||
backgroundColor: "color-mix(in srgb, var(--admin-text-primary) 55%, transparent)",
|
||||
backdropFilter: "blur(8px)",
|
||||
WebkitBackdropFilter: "blur(8px)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="atelier-enter atelier-canvas relative w-full max-w-5xl max-h-[calc(100vh-1.5rem)] sm:max-h-[calc(100vh-3rem)] rounded-2xl flex flex-col overflow-hidden border border-[var(--admin-border)]"
|
||||
style={{ boxShadow: "0 30px 80px -20px color-mix(in srgb, var(--admin-text-primary) 45%, transparent)" }}
|
||||
>
|
||||
{/* grain overlay */}
|
||||
<div className="atelier-grain" aria-hidden />
|
||||
|
||||
@@ -719,7 +719,8 @@ export default function ProductFormModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
</div>
|
||||
</dialog>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,33 @@ const Icons = {
|
||||
),
|
||||
};
|
||||
|
||||
async function resizeImage(file: File, maxWidth: number): Promise<ArrayBuffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = document.createElement("img");
|
||||
img.onload = () => {
|
||||
let { width, height } = img;
|
||||
if (width > maxWidth) {
|
||||
height = Math.round(height * (maxWidth / width));
|
||||
width = maxWidth;
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error("Failed to resize image"));
|
||||
return;
|
||||
}
|
||||
blob.arrayBuffer().then((buf) => resolve(buf));
|
||||
}, "image/jpeg", 0.85);
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export default function ProductsClient({
|
||||
products,
|
||||
brandId,
|
||||
@@ -75,33 +102,6 @@ export default function ProductsClient({
|
||||
const activeCount = products.filter((p) => p.active).length;
|
||||
const inactiveCount = products.filter((p) => !p.active).length;
|
||||
|
||||
async function resizeImage(file: File, maxWidth: number): Promise<ArrayBuffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = document.createElement("img");
|
||||
img.onload = () => {
|
||||
let { width, height } = img;
|
||||
if (width > maxWidth) {
|
||||
height = Math.round(height * (maxWidth / width));
|
||||
width = maxWidth;
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error("Failed to resize image"));
|
||||
return;
|
||||
}
|
||||
blob.arrayBuffer().then((buf) => resolve(buf));
|
||||
}, "image/jpeg", 0.85);
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
const handleUploadImage = useCallback(
|
||||
async (file: File): Promise<{ success: boolean; imageUrl?: string; error?: string }> => {
|
||||
const validTypes = ["image/png", "image/jpeg", "image/webp"];
|
||||
@@ -381,6 +381,7 @@ function TableView({
|
||||
const [menuPos, setMenuPos] = useState<{ top: number; left: number; width: number } | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const buttonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
const popupDialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
@@ -389,6 +390,21 @@ function TableView({
|
||||
init();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!deleteConfirm) return;
|
||||
const dialog = popupDialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (!dialog.open) dialog.showModal();
|
||||
const onCancel = (e: Event) => {
|
||||
e.preventDefault();
|
||||
onDeleteCancel();
|
||||
};
|
||||
dialog.addEventListener("cancel", onCancel);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", onCancel);
|
||||
};
|
||||
}, [deleteConfirm, onDeleteCancel]);
|
||||
|
||||
// Track the previous deleteConfirm value so we can adjust menuPos
|
||||
// inline during render when the prop changes — avoids a stale frame
|
||||
// between the prop change and the effect running. Use a lazy
|
||||
@@ -540,12 +556,11 @@ function TableView({
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-[60]"
|
||||
onClick={onDeleteCancel}
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
<dialog
|
||||
ref={popupDialogRef}
|
||||
aria-label="Confirm delete"
|
||||
className="fixed z-[70] w-72 rounded-xl bg-[var(--admin-card-bg)] border border-[var(--admin-border)] shadow-xl p-4"
|
||||
className="fixed z-[70] m-0 p-4 max-w-none max-h-none w-72 rounded-xl bg-[var(--admin-card-bg)] border border-[var(--admin-border)] shadow-xl"
|
||||
style={{ top: menuPos.top, left: menuPos.left, transform: "translateX(-100%)" }}
|
||||
>
|
||||
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">
|
||||
@@ -571,7 +586,7 @@ function TableView({
|
||||
{deletingId === openProduct.id ? "..." : "Delete"}
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { enqueueAction } from "@/lib/offline/queue";
|
||||
import { syncPending } from "@/lib/offline/sync";
|
||||
import { dispatchClientAction } from "@/actions/offline-dispatcher";
|
||||
@@ -59,6 +59,23 @@ export function StockAdjustButton({ productId, currentStock }: StockAdjustButton
|
||||
}
|
||||
}
|
||||
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (!dialog.open) dialog.showModal();
|
||||
const onCancel = (e: Event) => {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
};
|
||||
dialog.addEventListener("cancel", onCancel);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", onCancel);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
@@ -78,18 +95,20 @@ export function StockAdjustButton({ productId, currentStock }: StockAdjustButton
|
||||
±
|
||||
</button>
|
||||
{open && (
|
||||
<div role="dialog" aria-label="Adjust stock" className="fixed inset-0 z-50 flex items-end justify-center backdrop-blur-sm" style={{ backgroundColor: "rgba(0,0,0,0.4)" }}>
|
||||
<div className="w-full max-w-[640px] rounded-t-3xl p-6" style={{ backgroundColor: "var(--color-bg)", paddingBottom: "calc(env(safe-area-inset-bottom, 0) + 24px)" }}>
|
||||
<div className="w-12 h-1.5 rounded-full mx-auto mb-4" style={{ backgroundColor: "var(--color-surface-3)" }} />
|
||||
<h2 className="text-h1 font-display" style={{ fontWeight: 600 }}>Adjust stock</h2>
|
||||
<div className="mt-6 flex items-center justify-center gap-6">
|
||||
<button type="button" onClick={() => adjust(-1)} disabled={pending} className="rounded-full" style={{ width: "64px", height: "64px", backgroundColor: "var(--color-surface-2)", fontSize: "32px", fontWeight: 700 }}>−</button>
|
||||
<div className="text-display font-display" style={{ fontWeight: 600, minWidth: "100px", textAlign: "center" }}>{stock}</div>
|
||||
<button type="button" onClick={() => adjust(1)} disabled={pending} className="rounded-full" style={{ width: "64px", height: "64px", backgroundColor: "var(--color-accent)", color: "white", fontSize: "32px", fontWeight: 700 }}>+</button>
|
||||
<dialog ref={dialogRef} aria-label="Adjust stock" className="m-0 p-0 max-w-none max-h-none w-full h-full bg-transparent" style={{ backgroundColor: "transparent" }}>
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center backdrop-blur-sm" style={{ backgroundColor: "rgba(0,0,0,0.4)" }}>
|
||||
<div className="w-full max-w-[640px] rounded-t-3xl p-6" style={{ backgroundColor: "var(--color-bg)", paddingBottom: "calc(env(safe-area-inset-bottom, 0) + 24px)" }}>
|
||||
<div className="w-12 h-1.5 rounded-full mx-auto mb-4" style={{ backgroundColor: "var(--color-surface-3)" }} />
|
||||
<h2 className="text-h1 font-display" style={{ fontWeight: 600 }}>Adjust stock</h2>
|
||||
<div className="mt-6 flex items-center justify-center gap-6">
|
||||
<button type="button" onClick={() => adjust(-1)} disabled={pending} className="rounded-full" style={{ width: "64px", height: "64px", backgroundColor: "var(--color-surface-2)", fontSize: "32px", fontWeight: 700 }}>−</button>
|
||||
<div className="text-display font-display" style={{ fontWeight: 600, minWidth: "100px", textAlign: "center" }}>{stock}</div>
|
||||
<button type="button" onClick={() => adjust(1)} disabled={pending} className="rounded-full" style={{ width: "64px", height: "64px", backgroundColor: "var(--color-accent)", color: "white", fontSize: "32px", fontWeight: 700 }}>+</button>
|
||||
</div>
|
||||
<button type="button" onClick={() => setOpen(false)} className="w-full mt-6 rounded-xl text-label font-semibold" style={{ minHeight: "56px", backgroundColor: "var(--color-surface-2)", letterSpacing: "0.02em" }}>Done</button>
|
||||
</div>
|
||||
<button type="button" onClick={() => setOpen(false)} className="w-full mt-6 rounded-xl text-label font-semibold" style={{ minHeight: "56px", backgroundColor: "var(--color-surface-2)", letterSpacing: "0.02em" }}>Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
// One-color outline icons
|
||||
@@ -234,6 +234,23 @@ export default function FsmaReportModal({ brandId }: { brandId: string }) {
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (!dialog.open) dialog.showModal();
|
||||
const onCancel = (e: Event) => {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
};
|
||||
dialog.addEventListener("cancel", onCancel);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", onCancel);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const filteredLots = data?.lots.filter((lot) => {
|
||||
const matchesSearch =
|
||||
!search ||
|
||||
@@ -255,26 +272,26 @@ export default function FsmaReportModal({ brandId }: { brandId: string }) {
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
aria-label="FSMA report"
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/30"
|
||||
onClick={(e) => e.target === e.currentTarget && setOpen(false)}
|
||||
onKeyDown={(e) => { if (e.key === "Escape") setOpen(false); }}
|
||||
style={{ backdropFilter: "blur(4px)" }}
|
||||
className="m-0 p-0 max-w-none max-h-none w-full h-full bg-transparent"
|
||||
style={{ backgroundColor: "transparent" }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.2) 100%)",
|
||||
backdropFilter: "blur(60px) saturate(180%)",
|
||||
}}
|
||||
/>
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
style={{ backdropFilter: "blur(4px)" }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.2) 100%)",
|
||||
backdropFilter: "blur(60px) saturate(180%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Modal card */}
|
||||
<div
|
||||
{/* Modal card */}
|
||||
<div
|
||||
className="relative w-full max-w-5xl rounded-2xl max-h-[90vh] flex flex-col"
|
||||
style={{
|
||||
background: "rgba(255, 255, 255, 0.92)",
|
||||
@@ -612,7 +629,8 @@ export default function FsmaReportModal({ brandId }: { brandId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user