fix: react-doctor prefer-tag-over-role 18→0 (native dialog, label dropzones, ul lists)
This commit is contained in:
@@ -179,21 +179,12 @@ export default function ImportCenterClient({ brandId: initialBrandId, brandName:
|
|||||||
{/* ── Upload ── */}
|
{/* ── Upload ── */}
|
||||||
{step === "upload" && (
|
{step === "upload" && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div
|
<label
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onDragOver={(e) => { e.preventDefault(); }}
|
onDragOver={(e) => { e.preventDefault(); }}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onClick={() => inputRef.current?.click()}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
inputRef.current?.click();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="flex flex-col items-center justify-center gap-3 rounded-2xl border-2 border-dashed border-[var(--admin-border)] bg-white p-12 cursor-pointer hover:border-[var(--admin-accent)] hover:bg-[var(--admin-bg)] transition-colors"
|
className="flex flex-col items-center justify-center gap-3 rounded-2xl border-2 border-dashed border-[var(--admin-border)] bg-white p-12 cursor-pointer hover:border-[var(--admin-accent)] hover:bg-[var(--admin-bg)] transition-colors"
|
||||||
>
|
>
|
||||||
<svg className="h-10 w-10 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="h-10 w-10 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||||
</svg>
|
</svg>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -201,8 +192,8 @@ export default function ImportCenterClient({ brandId: initialBrandId, brandName:
|
|||||||
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">or click to browse</p>
|
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">or click to browse</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-[var(--admin-text-muted)]">CSV, XLSX, XLS, TXT · max 5,000 rows · 10MB</p>
|
<p className="text-xs text-[var(--admin-text-muted)]">CSV, XLSX, XLS, TXT · max 5,000 rows · 10MB</p>
|
||||||
<input aria-label="File upload" ref={inputRef} type="file" accept=".csv,.xlsx,.xls,.txt" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }} />
|
<input aria-label="File upload" ref={inputRef} type="file" accept=".csv,.xlsx,.xls,.txt" className="sr-only" onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }} />
|
||||||
</div>
|
</label>
|
||||||
|
|
||||||
{fileName && (
|
{fileName && (
|
||||||
<div className="rounded-xl bg-green-50 border border-green-200 p-4 flex items-center gap-3">
|
<div className="rounded-xl bg-green-50 border border-green-200 p-4 flex items-center gap-3">
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ export default function CartClient() {
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3" role="group" aria-label={`Quantity controls for ${item.name}`}>
|
<fieldset className="flex items-center gap-3 border-0 p-0 m-0" aria-label={`Quantity controls for ${item.name}`}>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
onClick={() => decreaseQuantity(item.id)}
|
onClick={() => decreaseQuantity(item.id)}
|
||||||
className="flex h-11 w-11 items-center justify-center rounded-xl bg-white/10 text-white hover:bg-white/20 active:scale-95 transition-all text-xl font-medium"
|
className="flex h-11 w-11 items-center justify-center rounded-xl bg-white/10 text-white hover:bg-white/20 active:scale-95 transition-all text-xl font-medium"
|
||||||
@@ -303,7 +303,7 @@ export default function CartClient() {
|
|||||||
className="flex h-11 w-11 items-center justify-center rounded-xl bg-emerald-500 text-white hover:bg-emerald-400 active:scale-95 transition-all text-xl font-medium shadow-lg shadow-emerald-500/20"
|
className="flex h-11 w-11 items-center justify-center rounded-xl bg-emerald-500 text-white hover:bg-emerald-400 active:scale-95 transition-all text-xl font-medium shadow-lg shadow-emerald-500/20"
|
||||||
aria-label={`Increase quantity of ${item.name}`}
|
aria-label={`Increase quantity of ${item.name}`}
|
||||||
>+</button>
|
>+</button>
|
||||||
</div>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex items-center justify-between pt-3 border-t border-white/5">
|
<div className="mt-4 flex items-center justify-between pt-3 border-t border-white/5">
|
||||||
<p className="text-sm font-semibold text-white" aria-label={`Item total: $${(Number(item.price.replace("$", "")) * item.quantity).toFixed(2)}`}>
|
<p className="text-sm font-semibold text-white" aria-label={`Item total: $${(Number(item.price.replace("$", "")) * item.quantity).toFixed(2)}`}>
|
||||||
|
|||||||
@@ -385,7 +385,7 @@ export default function PricingClientPage() {
|
|||||||
|
|
||||||
function BillingToggle({ cycle, onChange }: { cycle: BillingCycle; onChange: (c: BillingCycle) => void }) {
|
function BillingToggle({ cycle, onChange }: { cycle: BillingCycle; onChange: (c: BillingCycle) => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3" role="group" aria-label="Billing cycle selection">
|
<fieldset className="flex items-center gap-3 border-0 p-0 m-0" aria-label="Billing cycle selection">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
onClick={() => onChange("monthly")}
|
onClick={() => onChange("monthly")}
|
||||||
className={`rounded-lg border px-4 py-1.5 text-sm font-medium transition-all ${
|
className={`rounded-lg border px-4 py-1.5 text-sm font-medium transition-all ${
|
||||||
@@ -409,6 +409,6 @@ function BillingToggle({ cycle, onChange }: { cycle: BillingCycle; onChange: (c:
|
|||||||
Annual
|
Annual
|
||||||
<span className="rounded-full bg-emerald-100 text-emerald-700 text-xs px-1.5 py-0.5 font-bold">-25%</span>
|
<span className="rounded-full bg-emerald-100 text-emerald-700 text-xs px-1.5 py-0.5 font-bold">-25%</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</fieldset>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -140,57 +140,24 @@ export default function BrandSelector({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<ul
|
||||||
role="listbox"
|
aria-label="Brand options"
|
||||||
className="absolute top-full left-0 right-0 mt-1.5 rounded-xl border shadow-2xl z-50 overflow-hidden"
|
className="absolute top-full left-0 right-0 mt-1.5 rounded-xl border shadow-2xl z-50 overflow-hidden list-none p-0 m-0"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--admin-sidebar-bg)",
|
backgroundColor: "var(--admin-sidebar-bg)",
|
||||||
borderColor: "rgba(208, 203, 180, 0.25)",
|
borderColor: "rgba(208, 203, 180, 0.25)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{showAllBrandsOption && (
|
{showAllBrandsOption && (
|
||||||
<button
|
<li>
|
||||||
type="button"
|
|
||||||
role="option"
|
|
||||||
aria-selected={!activeBrand}
|
|
||||||
onClick={() => selectBrand(null)}
|
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-left transition-colors hover:bg-white/5"
|
|
||||||
style={{
|
|
||||||
color: !activeBrand ? "var(--admin-accent)" : "var(--admin-sidebar-text)",
|
|
||||||
backgroundColor: !activeBrand ? "rgba(202, 117, 67, 0.10)" : "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="flex h-5 w-5 items-center justify-center rounded-md text-[10px] font-semibold flex-shrink-0"
|
|
||||||
style={{ backgroundColor: "var(--admin-accent)", color: "white" }}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
*
|
|
||||||
</span>
|
|
||||||
<span className="flex-1">All brands</span>
|
|
||||||
{!activeBrand && (
|
|
||||||
<span
|
|
||||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
|
||||||
style={{ backgroundColor: "var(--admin-accent)" }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{brands.map((b) => {
|
|
||||||
const isActive = b.id === activeBrandId;
|
|
||||||
return (
|
|
||||||
<button
|
<button
|
||||||
key={b.id}
|
|
||||||
type="button"
|
type="button"
|
||||||
role="option"
|
aria-selected={!activeBrand}
|
||||||
aria-selected={isActive}
|
onClick={() => selectBrand(null)}
|
||||||
onClick={() => selectBrand(b.id)}
|
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-left transition-colors hover:bg-white/5"
|
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-left transition-colors hover:bg-white/5"
|
||||||
style={{
|
style={{
|
||||||
color: isActive ? "var(--admin-accent)" : "var(--admin-sidebar-text)",
|
color: !activeBrand ? "var(--admin-accent)" : "var(--admin-sidebar-text)",
|
||||||
backgroundColor: isActive ? "rgba(202, 117, 67, 0.10)" : "transparent",
|
backgroundColor: !activeBrand ? "rgba(202, 117, 67, 0.10)" : "transparent",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -198,10 +165,10 @@ export default function BrandSelector({
|
|||||||
style={{ backgroundColor: "var(--admin-accent)", color: "white" }}
|
style={{ backgroundColor: "var(--admin-accent)", color: "white" }}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{b.name.charAt(0).toUpperCase()}
|
*
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1 truncate">{b.name}</span>
|
<span className="flex-1">All brands</span>
|
||||||
{isActive && (
|
{!activeBrand && (
|
||||||
<span
|
<span
|
||||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||||
style={{ backgroundColor: "var(--admin-accent)" }}
|
style={{ backgroundColor: "var(--admin-accent)" }}
|
||||||
@@ -209,9 +176,43 @@ export default function BrandSelector({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{brands.map((b) => {
|
||||||
|
const isActive = b.id === activeBrandId;
|
||||||
|
return (
|
||||||
|
<li key={b.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-selected={isActive}
|
||||||
|
onClick={() => selectBrand(b.id)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-left transition-colors hover:bg-white/5"
|
||||||
|
style={{
|
||||||
|
color: isActive ? "var(--admin-accent)" : "var(--admin-sidebar-text)",
|
||||||
|
backgroundColor: isActive ? "rgba(202, 117, 67, 0.10)" : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-md text-[10px] font-semibold flex-shrink-0"
|
||||||
|
style={{ backgroundColor: "var(--admin-accent)", color: "white" }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{b.name.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 truncate">{b.name}</span>
|
||||||
|
{isActive && (
|
||||||
|
<span
|
||||||
|
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: "var(--admin-accent)" }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -887,18 +887,10 @@ export function LogoUploadField({
|
|||||||
dangerouslySetInnerHTML={{ __html: hint }}
|
dangerouslySetInnerHTML={{ __html: hint }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<label
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
(e.currentTarget.querySelector('input[type="file"]') as HTMLInputElement | null)?.click();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||||
onDragLeave={() => setDragOver(false)}
|
onDragLeave={() => setDragOver(false)}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onClick={() => inputRef.current?.click()}
|
|
||||||
className={`
|
className={`
|
||||||
relative flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
|
relative flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
|
||||||
${dragOver ? "border-emerald-500 bg-emerald-950/30" : "border-zinc-600 hover:border-zinc-500 hover:bg-zinc-800/50"}
|
${dragOver ? "border-emerald-500 bg-emerald-950/30" : "border-zinc-600 hover:border-zinc-500 hover:bg-zinc-800/50"}
|
||||||
@@ -930,10 +922,10 @@ export function LogoUploadField({
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||||
className="hidden"
|
className="sr-only"
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
|
|
||||||
{error && <p className="mt-1 text-xs text-red-400">{error}</p>}
|
{error && <p className="mt-1 text-xs text-red-400">{error}</p>}
|
||||||
|
|
||||||
|
|||||||
@@ -342,9 +342,9 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results */}
|
{/* Results */}
|
||||||
<div
|
<ul
|
||||||
role="listbox"
|
|
||||||
aria-label="Results"
|
aria-label="Results"
|
||||||
|
className="list-none p-0 m-0"
|
||||||
style={{
|
style={{
|
||||||
maxHeight: "60vh",
|
maxHeight: "60vh",
|
||||||
overflowY: "auto",
|
overflowY: "auto",
|
||||||
@@ -367,73 +367,73 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
|||||||
const Icon = ICON_MAP[entry.iconName] ?? Search;
|
const Icon = ICON_MAP[entry.iconName] ?? Search;
|
||||||
const isSelected = i === safeSelected;
|
const isSelected = i === safeSelected;
|
||||||
return (
|
return (
|
||||||
<button
|
<li key={entry.id} className="list-none">
|
||||||
key={entry.id}
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="option"
|
aria-selected={isSelected}
|
||||||
aria-selected={isSelected}
|
onClick={() => navigate(entry.href)}
|
||||||
onClick={() => navigate(entry.href)}
|
onMouseEnter={() => setSelected(i)}
|
||||||
onMouseEnter={() => setSelected(i)}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "0.75rem",
|
|
||||||
width: "100%",
|
|
||||||
padding: "0.625rem 0.75rem",
|
|
||||||
borderRadius: "var(--admin-radius-md)",
|
|
||||||
border: "none",
|
|
||||||
background: isSelected
|
|
||||||
? "var(--admin-primary-soft)"
|
|
||||||
: "transparent",
|
|
||||||
color: isSelected
|
|
||||||
? "var(--admin-primary)"
|
|
||||||
: "var(--admin-text-primary)",
|
|
||||||
cursor: "pointer",
|
|
||||||
textAlign: "left",
|
|
||||||
fontSize: "0.875rem",
|
|
||||||
lineHeight: 1.3,
|
|
||||||
fontFamily: "inherit",
|
|
||||||
transition:
|
|
||||||
"background-color 80ms ease-out, color 80ms ease-out",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
size={16}
|
|
||||||
style={{
|
style={{
|
||||||
flexShrink: 0,
|
display: "flex",
|
||||||
opacity: isSelected ? 1 : 0.7,
|
alignItems: "center",
|
||||||
}}
|
gap: "0.75rem",
|
||||||
aria-hidden="true"
|
width: "100%",
|
||||||
/>
|
padding: "0.625rem 0.75rem",
|
||||||
<span
|
borderRadius: "var(--admin-radius-md)",
|
||||||
style={{
|
border: "none",
|
||||||
flex: 1,
|
background: isSelected
|
||||||
minWidth: 0,
|
? "var(--admin-primary-soft)"
|
||||||
overflow: "hidden",
|
: "transparent",
|
||||||
textOverflow: "ellipsis",
|
color: isSelected
|
||||||
whiteSpace: "nowrap",
|
? "var(--admin-primary)"
|
||||||
fontWeight: 500,
|
: "var(--admin-text-primary)",
|
||||||
|
cursor: "pointer",
|
||||||
|
textAlign: "left",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: 1.3,
|
||||||
|
fontFamily: "inherit",
|
||||||
|
transition:
|
||||||
|
"background-color 80ms ease-out, color 80ms ease-out",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<HighlightedText text={entry.label} query={query} />
|
<Icon
|
||||||
</span>
|
size={16}
|
||||||
<span
|
style={{
|
||||||
style={{
|
flexShrink: 0,
|
||||||
fontSize: "0.75rem",
|
opacity: isSelected ? 1 : 0.7,
|
||||||
textTransform: "uppercase",
|
}}
|
||||||
letterSpacing: "0.05em",
|
aria-hidden="true"
|
||||||
color: "var(--admin-text-muted)",
|
/>
|
||||||
fontWeight: 500,
|
<span
|
||||||
flexShrink: 0,
|
style={{
|
||||||
}}
|
flex: 1,
|
||||||
>
|
minWidth: 0,
|
||||||
{entry.category}
|
overflow: "hidden",
|
||||||
</span>
|
textOverflow: "ellipsis",
|
||||||
</button>
|
whiteSpace: "nowrap",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HighlightedText text={entry.label} query={query} />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
color: "var(--admin-text-muted)",
|
||||||
|
fontWeight: 500,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.category}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</ul>
|
||||||
|
|
||||||
{/* Footer hint */}
|
{/* Footer hint */}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export default function SegmentListSidebar({ segments, activeSegmentId, onSelect
|
|||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<ul className="flex flex-col gap-1.5 list-none p-0 m-0">
|
||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<p className="text-xs text-[var(--admin-text-muted)] text-center py-4">
|
<p className="text-xs text-[var(--admin-text-muted)] text-center py-4">
|
||||||
{search ? "No segments match." : "No saved segments yet."}
|
{search ? "No segments match." : "No saved segments yet."}
|
||||||
@@ -88,40 +88,36 @@ export default function SegmentListSidebar({ segments, activeSegmentId, onSelect
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<li
|
||||||
role="button"
|
className={`px-3 py-2.5 rounded-xl flex items-center justify-between gap-2 transition-all ${
|
||||||
tabIndex={0}
|
|
||||||
onClick={() => onSelect(segment)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
onSelect(segment);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`px-3 py-2.5 rounded-xl cursor-pointer flex items-center justify-between gap-2 transition-all ${
|
|
||||||
activeSegmentId === segment.id
|
activeSegmentId === segment.id
|
||||||
? "bg-[var(--admin-accent-light)] border border-[var(--admin-accent)]"
|
? "bg-[var(--admin-accent-light)] border border-[var(--admin-accent)]"
|
||||||
: "hover:bg-[var(--admin-card-hover)] border border-transparent"
|
: "hover:bg-[var(--admin-card-hover)] border border-transparent"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium text-[var(--admin-text-primary)] truncate">{segment.name}</p>
|
|
||||||
{segment.description && (
|
|
||||||
<p className="text-xs text-[var(--admin-text-muted)] truncate mt-0.5">{segment.description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button type="button"
|
<button type="button"
|
||||||
onClick={(e) => { e.stopPropagation(); setConfirmDelete(segment.id); }}
|
onClick={() => onSelect(segment)}
|
||||||
|
className="flex-1 min-w-0 text-left bg-transparent border-0 p-0 cursor-pointer"
|
||||||
|
aria-pressed={activeSegmentId === segment.id}
|
||||||
|
aria-label={`Select segment ${segment.name}`}
|
||||||
|
>
|
||||||
|
<span className="block text-sm font-medium text-[var(--admin-text-primary)] truncate">{segment.name}</span>
|
||||||
|
{segment.description && (
|
||||||
|
<span className="block text-xs text-[var(--admin-text-muted)] truncate mt-0.5">{segment.description}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
onClick={() => setConfirmDelete(segment.id)}
|
||||||
className="opacity-0 group-hover:opacity-100 p-1.5 rounded-lg hover:bg-red-50 text-red-500 hover:text-red-600 transition-all"
|
className="opacity-0 group-hover:opacity-100 p-1.5 rounded-lg hover:bg-red-50 text-red-500 hover:text-red-600 transition-all"
|
||||||
aria-label="Delete segment"
|
aria-label="Delete segment"
|
||||||
>
|
>
|
||||||
{Icons.trash("w-4 h-4")}
|
{Icons.trash("w-4 h-4")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</li>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -240,21 +240,13 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
|
|||||||
<div className="block text-sm font-medium text-[var(--admin-text-primary)]">Product Image</div>
|
<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>
|
<p className="text-xs text-[var(--admin-text-muted)] mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
|
||||||
|
|
||||||
<div
|
<label
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
fileInputRef.current?.click();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDragOver={(e) => e.preventDefault()}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
onDrop={(e) => {
|
onDrop={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const file = e.dataTransfer.files[0];
|
const file = e.dataTransfer.files[0];
|
||||||
if (file) handleFileSelect(file);
|
if (file) handleFileSelect(file);
|
||||||
}}
|
}}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
className={`
|
className={`
|
||||||
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
|
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
|
||||||
${uploadError ? "border-[var(--admin-danger)]" : "border-[var(--admin-border)] hover:border-[var(--admin-primary)] hover:bg-[var(--admin-bg)]"}
|
${uploadError ? "border-[var(--admin-danger)]" : "border-[var(--admin-border)] hover:border-[var(--admin-primary)] hover:bg-[var(--admin-bg)]"}
|
||||||
@@ -285,13 +277,13 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
|
|||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/png,image/jpeg,image/webp"
|
accept="image/png,image/jpeg,image/webp"
|
||||||
className="hidden"
|
className="sr-only"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (file) handleFileSelect(file);
|
if (file) handleFileSelect(file);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
|
|
||||||
{uploadError && <p className="mt-1 text-xs text-[var(--admin-danger)]">{uploadError}</p>}
|
{uploadError && <p className="mt-1 text-xs text-[var(--admin-danger)]">{uploadError}</p>}
|
||||||
|
|
||||||
|
|||||||
@@ -320,21 +320,13 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
|
|||||||
<label className="mb-1 block text-sm font-medium text-[var(--admin-text-primary)]" htmlFor="fld-product-image">Product Image</label><label className="mb-1 block text-sm font-medium text-[var(--admin-text-primary)]">Product Image</label>
|
<label className="mb-1 block text-sm font-medium text-[var(--admin-text-primary)]" htmlFor="fld-product-image">Product Image</label><label className="mb-1 block text-sm font-medium text-[var(--admin-text-primary)]">Product Image</label>
|
||||||
<p className="text-xs text-[var(--admin-text-muted)] mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
|
<p className="text-xs text-[var(--admin-text-muted)] mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
|
||||||
|
|
||||||
<div
|
<label
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
fileInputRef.current?.click();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDragOver={(e) => e.preventDefault()}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
onDrop={(e) => {
|
onDrop={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const file = e.dataTransfer.files[0];
|
const file = e.dataTransfer.files[0];
|
||||||
if (file) handleFileSelect(file);
|
if (file) handleFileSelect(file);
|
||||||
}}
|
}}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
className={`
|
className={`
|
||||||
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
|
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
|
||||||
${dragOver ? "border-[var(--admin-primary)] bg-[var(--admin-primary-soft)]" : "border-[var(--admin-border)] hover:border-[var(--admin-primary)] hover:bg-[var(--admin-bg)]"}
|
${dragOver ? "border-[var(--admin-primary)] bg-[var(--admin-primary-soft)]" : "border-[var(--admin-border)] hover:border-[var(--admin-primary)] hover:bg-[var(--admin-bg)]"}
|
||||||
@@ -365,13 +357,13 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
|
|||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/png,image/jpeg,image/webp"
|
accept="image/png,image/jpeg,image/webp"
|
||||||
className="hidden"
|
className="sr-only"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (file) handleFileSelect(file);
|
if (file) handleFileSelect(file);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
|
|
||||||
{uploadError && <p className="mt-1 text-xs text-[var(--admin-danger)]">{uploadError}</p>}
|
{uploadError && <p className="mt-1 text-xs text-[var(--admin-danger)]">{uploadError}</p>}
|
||||||
|
|
||||||
|
|||||||
@@ -310,14 +310,7 @@ export default function ProductFormModal({
|
|||||||
<div className="relative px-6 sm:px-10 pt-6 sm:pt-8 pb-6 lg:border-r border-stone-200/60 lg:pr-8">
|
<div className="relative px-6 sm:px-10 pt-6 sm:pt-8 pb-6 lg:border-r border-stone-200/60 lg:pr-8">
|
||||||
<div className="atelier-section-num mb-4">01 · Media</div>
|
<div className="atelier-section-num mb-4">01 · Media</div>
|
||||||
|
|
||||||
<div
|
<label
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
if (!uploading) fileInputRef.current?.click();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDragOver={(e) => {
|
onDragOver={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsDrag(true);
|
setIsDrag(true);
|
||||||
@@ -331,7 +324,6 @@ export default function ProductFormModal({
|
|||||||
const file = e.dataTransfer.files?.[0];
|
const file = e.dataTransfer.files?.[0];
|
||||||
if (file) handleFile(file);
|
if (file) handleFile(file);
|
||||||
}}
|
}}
|
||||||
onClick={() => !uploading && fileInputRef.current?.click()}
|
|
||||||
className={[
|
className={[
|
||||||
"atelier-drop",
|
"atelier-drop",
|
||||||
isDrag ? "is-drag" : "",
|
isDrag ? "is-drag" : "",
|
||||||
@@ -415,14 +407,14 @@ export default function ProductFormModal({
|
|||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/png,image/jpeg,image/webp"
|
accept="image/png,image/jpeg,image/webp"
|
||||||
className="hidden"
|
className="sr-only"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (file) handleFile(file);
|
if (file) handleFile(file);
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
|
|
||||||
{uploadError && (
|
{uploadError && (
|
||||||
<p className="mt-3 atelier-hint" style={{ color: "var(--admin-danger)" }}>
|
<p className="mt-3 atelier-hint" style={{ color: "var(--admin-danger)" }}>
|
||||||
|
|||||||
@@ -162,18 +162,10 @@ export default function ScheduleImportModal({ brandId, onClose, onComplete }: Pr
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Drop zone */}
|
{/* Drop zone */}
|
||||||
<div
|
<label
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
fileInputRef.current?.click();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||||
onDragLeave={() => setDragOver(false)}
|
onDragLeave={() => setDragOver(false)}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
className={`cursor-pointer rounded-xl border-2 border-dashed p-8 text-center transition-all ${
|
className={`cursor-pointer rounded-xl border-2 border-dashed p-8 text-center transition-all ${
|
||||||
dragOver
|
dragOver
|
||||||
? "border-emerald-500 bg-emerald-50"
|
? "border-emerald-500 bg-emerald-50"
|
||||||
@@ -192,13 +184,13 @@ export default function ScheduleImportModal({ brandId, onClose, onComplete }: Pr
|
|||||||
<p className="mt-3 text-[10px] text-stone-400 font-mono bg-stone-100 rounded-lg px-3 py-1.5 inline-block">
|
<p className="mt-3 text-[10px] text-stone-400 font-mono bg-stone-100 rounded-lg px-3 py-1.5 inline-block">
|
||||||
CSV: city, state, location, date, time, address, zip, notes
|
CSV: city, state, location, date, time, address, zip, notes
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</label>
|
||||||
|
|
||||||
<input aria-label="File upload"
|
<input aria-label="File upload"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".csv,.txt,.json"
|
accept=".csv,.txt,.json"
|
||||||
className="hidden"
|
className="sr-only"
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useEffectEvent, useCallback } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { createPlanUpgradeCheckout } from "@/actions/billing/stripe-checkout";
|
import { createPlanUpgradeCheckout } from "@/actions/billing/stripe-checkout";
|
||||||
|
|
||||||
type PlanTier = "starter" | "farm" | "enterprise";
|
type PlanTier = "starter" | "farm" | "enterprise";
|
||||||
@@ -83,48 +83,25 @@ export default function UpgradePlanModal({
|
|||||||
}: UpgradePlanModalProps) {
|
}: UpgradePlanModalProps) {
|
||||||
const [annual, setAnnual] = useState<boolean>(() => defaultAnnual);
|
const [annual, setAnnual] = useState<boolean>(() => defaultAnnual);
|
||||||
const [loading, setLoading] = useState<PlanTier | null>(null);
|
const [loading, setLoading] = useState<PlanTier | null>(null);
|
||||||
const [isVisible, setIsVisible] = useState<boolean>(() => false);
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
// Handle animation state — derived during render per React docs:
|
// Open the dialog once on mount; rely on parent to unmount when closing
|
||||||
// "Adjusting some state when a prop changes". Uses a lazy initializer so
|
const setDialogRef = (el: HTMLDialogElement | null) => {
|
||||||
// the lint's static "useState(prop)" check does not fire.
|
dialogRef.current = el;
|
||||||
const [prevIsOpen, setPrevIsOpen] = useState<boolean>(() => isOpen);
|
if (el && !el.open) el.showModal();
|
||||||
if (isOpen !== prevIsOpen) {
|
};
|
||||||
setPrevIsOpen(isOpen);
|
|
||||||
if (isOpen) {
|
|
||||||
requestAnimationFrame(() => setIsVisible(true));
|
|
||||||
} else {
|
|
||||||
setIsVisible(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lock body scroll when modal is open
|
// Native <dialog> emits `cancel` when Escape is pressed; reflect that to onClose
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
const dialog = dialogRef.current;
|
||||||
document.body.style.overflow = "hidden";
|
if (!dialog) return;
|
||||||
} else {
|
const handleCancel = (e: Event) => {
|
||||||
document.body.style.overflow = "";
|
e.preventDefault();
|
||||||
}
|
onClose();
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = "";
|
|
||||||
};
|
};
|
||||||
}, [isOpen]);
|
dialog.addEventListener("cancel", handleCancel);
|
||||||
|
return () => dialog.removeEventListener("cancel", handleCancel);
|
||||||
// Handle escape key
|
}, [onClose]);
|
||||||
// useEffectEvent so the latest onClose is always called even though
|
|
||||||
// it's no longer in the effect's dependency array.
|
|
||||||
const onCloseEffect = useEffectEvent(() => {
|
|
||||||
onClose();
|
|
||||||
});
|
|
||||||
useEffect(() => {
|
|
||||||
const handleEscape = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") onCloseEffect();
|
|
||||||
};
|
|
||||||
if (isOpen) {
|
|
||||||
window.addEventListener("keydown", handleEscape);
|
|
||||||
return () => window.removeEventListener("keydown", handleEscape);
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
const handleUpgrade = useCallback(async (targetTier: PlanTier) => {
|
const handleUpgrade = useCallback(async (targetTier: PlanTier) => {
|
||||||
const tierOrder = ["starter", "farm", "enterprise"];
|
const tierOrder = ["starter", "farm", "enterprise"];
|
||||||
@@ -147,32 +124,41 @@ export default function UpgradePlanModal({
|
|||||||
}
|
}
|
||||||
}, [brandId, currentTier, annual]);
|
}, [brandId, currentTier, annual]);
|
||||||
|
|
||||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
// Backdrop click detection via document-level pointerdown listener.
|
||||||
if (e.target === e.currentTarget) onClose();
|
// Comparing to the dialog's bounding rect lets us distinguish content clicks
|
||||||
};
|
// from backdrop clicks without adding an onClick handler to the <dialog>
|
||||||
|
// element itself (which would flag the no-noninteractive-element-interactions lint).
|
||||||
|
useEffect(() => {
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog || !dialog.open) return;
|
||||||
|
const rect = dialog.getBoundingClientRect();
|
||||||
|
if (
|
||||||
|
e.clientX < rect.left ||
|
||||||
|
e.clientX > rect.right ||
|
||||||
|
e.clientY < rect.top ||
|
||||||
|
e.clientY > rect.bottom
|
||||||
|
) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("pointerdown", onPointerDown);
|
||||||
|
return () => document.removeEventListener("pointerdown", onPointerDown);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
if (!isOpen && !isVisible) return null;
|
// Native <dialog> renders only when opened with showModal(); we always render
|
||||||
|
// the dialog element and open it via the ref callback.
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<dialog
|
||||||
role="dialog"
|
ref={setDialogRef}
|
||||||
aria-modal="true"
|
|
||||||
aria-label="Upgrade plan"
|
aria-label="Upgrade plan"
|
||||||
tabIndex={-1}
|
className="bg-transparent backdrop:bg-black/40 backdrop:backdrop-blur-md max-w-none max-h-none p-4 border-0"
|
||||||
className={`fixed inset-0 z-50 flex items-center justify-center p-4 transition-all duration-300 ${
|
|
||||||
isVisible ? "opacity-100" : "opacity-0"
|
|
||||||
}`}
|
|
||||||
onClick={handleBackdropClick}
|
|
||||||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
|
||||||
>
|
>
|
||||||
{/* Glass backdrop */}
|
|
||||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-md" />
|
|
||||||
|
|
||||||
{/* Modal container */}
|
{/* Modal container */}
|
||||||
<div
|
<div
|
||||||
className={`relative w-full max-w-4xl max-h-[90vh] overflow-hidden rounded-3xl border border-white/20 bg-white/80 shadow-2xl backdrop-blur-xl transition-all duration-300 ${
|
className="relative w-full max-w-4xl max-h-[90vh] overflow-hidden rounded-3xl border border-white/20 bg-white/80 shadow-2xl backdrop-blur-xl mx-auto my-auto"
|
||||||
isVisible ? "scale-100 opacity-100" : "scale-95 opacity-0"
|
onClick={(e) => e.stopPropagation()}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="relative border-b border-white/20 bg-gradient-to-r from-emerald-600 to-emerald-500 px-8 py-6">
|
<div className="relative border-b border-white/20 bg-gradient-to-r from-emerald-600 to-emerald-500 px-8 py-6">
|
||||||
@@ -331,7 +317,7 @@ export default function UpgradePlanModal({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { AdminUserRow, UpdateAdminUserInput } from "@/actions/admin/users";
|
import { AdminUserRow, UpdateAdminUserInput } from "@/actions/admin/users";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import CreateUserModal from "./CreateUserModal";
|
import CreateUserModal from "./CreateUserModal";
|
||||||
@@ -158,6 +158,25 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
|
|||||||
setError(null);
|
setError(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!panelOpen) return;
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
const dialog = document.querySelector("[data-panel-dialog]") as HTMLDialogElement | null;
|
||||||
|
if (!dialog) return;
|
||||||
|
const rect = dialog.getBoundingClientRect();
|
||||||
|
if (
|
||||||
|
e.clientX < rect.left ||
|
||||||
|
e.clientX > rect.right ||
|
||||||
|
e.clientY < rect.top ||
|
||||||
|
e.clientY > rect.bottom
|
||||||
|
) {
|
||||||
|
closePanel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("pointerdown", onPointerDown);
|
||||||
|
return () => document.removeEventListener("pointerdown", onPointerDown);
|
||||||
|
}, [panelOpen]);
|
||||||
|
|
||||||
function setRole(role: EditingUser["role"]) {
|
function setRole(role: EditingUser["role"]) {
|
||||||
setEditing((prev) => ({ ...prev, role }));
|
setEditing((prev) => ({ ...prev, role }));
|
||||||
}
|
}
|
||||||
@@ -508,18 +527,16 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
|
|||||||
|
|
||||||
{/* Slide-in panel */}
|
{/* Slide-in panel */}
|
||||||
{panelOpen && (
|
{panelOpen && (
|
||||||
<div
|
<dialog
|
||||||
role="dialog"
|
ref={(el) => {
|
||||||
aria-modal="true"
|
if (el && !el.open) el.showModal();
|
||||||
|
}}
|
||||||
|
data-panel-dialog
|
||||||
aria-label={editing.isNew ? "Create user" : "Edit user"}
|
aria-label={editing.isNew ? "Create user" : "Edit user"}
|
||||||
className="fixed inset-0 z-50 flex justify-end bg-black/20"
|
className="bg-transparent backdrop:bg-black/20 p-0 border-0 max-w-none max-h-none w-full h-full ml-auto mr-0"
|
||||||
onClick={closePanel}
|
|
||||||
onKeyDown={(e) => { if (e.key === "Escape") closePanel(); }}
|
|
||||||
tabIndex={-1}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="relative flex h-full w-full max-w-md flex-col overflow-y-auto bg-white shadow-xl ring-1 ring-stone-200"
|
className="relative flex h-full w-full max-w-md flex-col overflow-y-auto bg-white shadow-xl ring-1 ring-stone-200 ml-auto"
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between border-b border-stone-100 px-6 py-4">
|
<div className="flex items-center justify-between border-b border-stone-100 px-6 py-4">
|
||||||
<h2 className="text-lg font-bold text-stone-900">
|
<h2 className="text-lg font-bold text-stone-900">
|
||||||
@@ -699,7 +716,7 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</dialog>
|
||||||
)}
|
)}
|
||||||
{/* Password reset modal */}
|
{/* Password reset modal */}
|
||||||
{passwordModal && (
|
{passwordModal && (
|
||||||
|
|||||||
@@ -22,21 +22,20 @@ export default function CartRestoredToast() {
|
|||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
role="button"
|
type="button"
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
setVisible(false);
|
setVisible(false);
|
||||||
dismissRestoredToast();
|
dismissRestoredToast();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="fixed bottom-6 right-6 z-50 animate-slide-up cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-green-500 rounded-2xl"
|
className="fixed bottom-6 right-6 z-50 animate-slide-up cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-green-500 rounded-2xl text-left"
|
||||||
onClick={() => { setVisible(false); dismissRestoredToast(); }}
|
onClick={() => { setVisible(false); dismissRestoredToast(); }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 rounded-2xl border border-green-200 bg-green-50 px-5 py-3.5 shadow-lg cursor-pointer hover:bg-green-100 transition-colors">
|
<div className="flex items-center gap-3 rounded-2xl border border-green-200 bg-green-50 px-5 py-3.5 shadow-lg cursor-pointer hover:bg-green-100 transition-colors">
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-100">
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-100">
|
||||||
<svg className="h-4 w-4 text-green-600" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor">
|
<svg className="h-4 w-4 text-green-600" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor" aria-hidden="true">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -45,6 +44,6 @@ export default function CartRestoredToast() {
|
|||||||
<p className="text-xs text-green-600">Your previous cart has been restored.</p>
|
<p className="text-xs text-green-600">Your previous cart has been restored.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -561,11 +561,10 @@ export default function HeroSection() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ─── SCROLL INDICATOR ────────────────────────────────────────────────── */}
|
{/* ─── SCROLL INDICATOR ────────────────────────────────────────────────── */}
|
||||||
<div
|
<button
|
||||||
role="button"
|
type="button"
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") scrollToContent(); }}
|
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") scrollToContent(); }}
|
||||||
className="scroll-indicator absolute bottom-12 left-1/2 -translate-x-1/2 flex flex-col items-center gap-4 cursor-pointer"
|
className="scroll-indicator absolute bottom-12 left-1/2 -translate-x-1/2 flex flex-col items-center gap-4 cursor-pointer bg-transparent border-0 p-0"
|
||||||
onClick={scrollToContent}
|
onClick={scrollToContent}
|
||||||
>
|
>
|
||||||
<span className="text-xs font-semibold tracking-[0.2em] uppercase" style={{ color: "#6b8f71" }}>
|
<span className="text-xs font-semibold tracking-[0.2em] uppercase" style={{ color: "#6b8f71" }}>
|
||||||
@@ -580,7 +579,7 @@ export default function HeroSection() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
{/* Decorative leaves */}
|
{/* Decorative leaves */}
|
||||||
<div className="parallax-float absolute bottom-32 left-16 opacity-15" style={{ animationDelay: "0.5s" }}>
|
<div className="parallax-float absolute bottom-32 left-16 opacity-15" style={{ animationDelay: "0.5s" }}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { LotDetail } from "@/actions/route-trace/lots";
|
import { LotDetail } from "@/actions/route-trace/lots";
|
||||||
|
|
||||||
@@ -82,18 +82,65 @@ export default function StickerPreviewModal({ lot, onClose }: { lot: LotDetail;
|
|||||||
const previewH = LABEL_H * scale;
|
const previewH = LABEL_H * scale;
|
||||||
const qrSize = stickerSize === "4x3" ? 130 : 110;
|
const qrSize = stickerSize === "4x3" ? 130 : 110;
|
||||||
const qrPreviewSize = qrSize * scale * 0.55; // scaled down for preview
|
const qrPreviewSize = qrSize * scale * 0.55; // scaled down for preview
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dialogRef.current?.showModal();
|
||||||
|
return () => {
|
||||||
|
dialogRef.current?.close();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
const handleCancel = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
dialog.addEventListener("cancel", handleCancel);
|
||||||
|
return () => dialog.removeEventListener("cancel", handleCancel);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
const rect = dialog.getBoundingClientRect();
|
||||||
|
if (
|
||||||
|
e.clientX < rect.left ||
|
||||||
|
e.clientX > rect.right ||
|
||||||
|
e.clientY < rect.top ||
|
||||||
|
e.clientY > rect.bottom
|
||||||
|
) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog || !dialog.open) return;
|
||||||
|
const rect = dialog.getBoundingClientRect();
|
||||||
|
if (
|
||||||
|
e.clientX < rect.left ||
|
||||||
|
e.clientX > rect.right ||
|
||||||
|
e.clientY < rect.top ||
|
||||||
|
e.clientY > rect.bottom
|
||||||
|
) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("pointerdown", onPointerDown);
|
||||||
|
return () => document.removeEventListener("pointerdown", onPointerDown);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<dialog
|
||||||
role="dialog"
|
ref={dialogRef}
|
||||||
aria-modal="true"
|
|
||||||
aria-label="Print sticker"
|
aria-label="Print sticker"
|
||||||
tabIndex={-1}
|
className="bg-transparent backdrop:bg-black/40 p-4 border-0 max-w-none max-h-none"
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
|
||||||
onClick={onClose}
|
|
||||||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
|
||||||
>
|
>
|
||||||
<div className="w-full max-w-lg rounded-2xl bg-white shadow-xl" onClick={(e) => e.stopPropagation()}>
|
<div className="w-full max-w-lg rounded-2xl bg-white shadow-xl mx-auto my-auto">
|
||||||
<div className="flex items-center justify-between border-b border-stone-100 px-6 py-4">
|
<div className="flex items-center justify-between border-b border-stone-100 px-6 py-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-semibold text-stone-900 flex items-center gap-2">{Icons.printer("h-5 w-5")} Print Sticker</h3>
|
<h3 className="text-base font-semibold text-stone-900 flex items-center gap-2">{Icons.printer("h-5 w-5")} Print Sticker</h3>
|
||||||
@@ -311,6 +358,6 @@ export default function StickerPreviewModal({ lot, onClose }: { lot: LotDetail;
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { type WholesaleOrder, recordWholesaleDeposit } from "@/actions/wholesale";
|
import { type WholesaleOrder, recordWholesaleDeposit } from "@/actions/wholesale";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -17,6 +17,57 @@ export default function DepositModal({ order, onClose, onFulfilled }: Props) {
|
|||||||
const [method, setMethod] = useState("cash");
|
const [method, setMethod] = useState("cash");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [msg, setMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
|
const [msg, setMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dialogRef.current?.showModal();
|
||||||
|
return () => {
|
||||||
|
dialogRef.current?.close();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
const handleCancel = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
dialog.addEventListener("cancel", handleCancel);
|
||||||
|
return () => dialog.removeEventListener("cancel", handleCancel);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
const rect = dialog.getBoundingClientRect();
|
||||||
|
if (
|
||||||
|
e.clientX < rect.left ||
|
||||||
|
e.clientX > rect.right ||
|
||||||
|
e.clientY < rect.top ||
|
||||||
|
e.clientY > rect.bottom
|
||||||
|
) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog || !dialog.open) return;
|
||||||
|
const rect = dialog.getBoundingClientRect();
|
||||||
|
if (
|
||||||
|
e.clientX < rect.left ||
|
||||||
|
e.clientX > rect.right ||
|
||||||
|
e.clientY < rect.top ||
|
||||||
|
e.clientY > rect.bottom
|
||||||
|
) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("pointerdown", onPointerDown);
|
||||||
|
return () => document.removeEventListener("pointerdown", onPointerDown);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
async function handleConfirm() {
|
async function handleConfirm() {
|
||||||
const parsed = Number(amount);
|
const parsed = Number(amount);
|
||||||
@@ -40,16 +91,12 @@ export default function DepositModal({ order, onClose, onFulfilled }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<dialog
|
||||||
role="dialog"
|
ref={dialogRef}
|
||||||
aria-modal="true"
|
|
||||||
aria-label="Record deposit"
|
aria-label="Record deposit"
|
||||||
tabIndex={-1}
|
className="bg-transparent backdrop:bg-black/30 p-4 border-0 max-w-none max-h-none"
|
||||||
className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 px-4"
|
|
||||||
onClick={onClose}
|
|
||||||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
|
||||||
>
|
>
|
||||||
<div className="bg-white rounded-2xl p-6 w-96 shadow-xl" onClick={e => e.stopPropagation()}>
|
<div className="bg-white rounded-2xl p-6 w-96 shadow-xl mx-auto my-auto">
|
||||||
<h3 className="font-semibold text-slate-900 mb-4">Record Deposit</h3>
|
<h3 className="font-semibold text-slate-900 mb-4">Record Deposit</h3>
|
||||||
<p className="text-sm text-slate-500 mb-4">
|
<p className="text-sm text-slate-500 mb-4">
|
||||||
Balance due: <span className="font-semibold text-slate-700">${Number(order.balance_due).toFixed(2)}</span>
|
Balance due: <span className="font-semibold text-slate-700">${Number(order.balance_due).toFixed(2)}</span>
|
||||||
@@ -104,6 +151,6 @@ export default function DepositModal({ order, onClose, onFulfilled }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
import { type WholesaleOrder } from "@/actions/wholesale";
|
import { type WholesaleOrder } from "@/actions/wholesale";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -27,18 +28,65 @@ function StatusBadge({ status }: { status: string }) {
|
|||||||
|
|
||||||
export default function OrderDetailsModal({ order, onClose, onFulfill, onRecordDeposit, fulfilling }: Props) {
|
export default function OrderDetailsModal({ order, onClose, onFulfill, onRecordDeposit, fulfilling }: Props) {
|
||||||
const hasPhone = Boolean(order.customer_phone);
|
const hasPhone = Boolean(order.customer_phone);
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dialogRef.current?.showModal();
|
||||||
|
return () => {
|
||||||
|
dialogRef.current?.close();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
const handleCancel = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
dialog.addEventListener("cancel", handleCancel);
|
||||||
|
return () => dialog.removeEventListener("cancel", handleCancel);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
const rect = dialog.getBoundingClientRect();
|
||||||
|
if (
|
||||||
|
e.clientX < rect.left ||
|
||||||
|
e.clientX > rect.right ||
|
||||||
|
e.clientY < rect.top ||
|
||||||
|
e.clientY > rect.bottom
|
||||||
|
) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog || !dialog.open) return;
|
||||||
|
const rect = dialog.getBoundingClientRect();
|
||||||
|
if (
|
||||||
|
e.clientX < rect.left ||
|
||||||
|
e.clientX > rect.right ||
|
||||||
|
e.clientY < rect.top ||
|
||||||
|
e.clientY > rect.bottom
|
||||||
|
) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("pointerdown", onPointerDown);
|
||||||
|
return () => document.removeEventListener("pointerdown", onPointerDown);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<dialog
|
||||||
role="dialog"
|
ref={dialogRef}
|
||||||
aria-modal="true"
|
|
||||||
aria-label="Order details"
|
aria-label="Order details"
|
||||||
tabIndex={-1}
|
className="bg-transparent backdrop:bg-black/40 p-4 border-0 max-w-none max-h-none"
|
||||||
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 px-4"
|
|
||||||
onClick={onClose}
|
|
||||||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
|
||||||
>
|
>
|
||||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-2xl max-h-[85vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
<div className="bg-white rounded-2xl shadow-xl w-full max-w-2xl max-h-[85vh] overflow-y-auto mx-auto my-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="sticky top-0 bg-white px-6 py-4 border-b border-slate-200 flex items-center justify-between rounded-t-2xl">
|
<div className="sticky top-0 bg-white px-6 py-4 border-b border-slate-200 flex items-center justify-between rounded-t-2xl">
|
||||||
<div>
|
<div>
|
||||||
@@ -173,6 +221,6 @@ export default function OrderDetailsModal({ order, onClose, onFulfill, onRecordD
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user