fix: react-doctor dialog/a11y/labels → 68/100 with 973 warnings
This commit is contained in:
@@ -108,85 +108,3 @@ await getSession(); const adminUser = await getAdminUser();
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertSegment(params: {
|
|
||||||
id?: string;
|
|
||||||
brand_id: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
rules: AudienceRules;
|
|
||||||
}): Promise<UpsertSegmentResult> {
|
|
||||||
|
|
||||||
await getSession(); const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
|
|
||||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brand_id) {
|
|
||||||
return { success: false, error: "Not authorized" };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const segments = await loadSegments(params.brand_id);
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
let saved: Segment;
|
|
||||||
if (params.id) {
|
|
||||||
const idx = segments.findIndex((s) => s.id === params.id);
|
|
||||||
if (idx === -1) {
|
|
||||||
return { success: false, error: "Segment not found" };
|
|
||||||
}
|
|
||||||
saved = {
|
|
||||||
...segments[idx],
|
|
||||||
name: params.name,
|
|
||||||
description: params.description ?? null,
|
|
||||||
rules: params.rules,
|
|
||||||
updated_at: now,
|
|
||||||
};
|
|
||||||
segments[idx] = saved;
|
|
||||||
} else {
|
|
||||||
saved = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
brand_id: params.brand_id,
|
|
||||||
name: params.name,
|
|
||||||
description: params.description ?? null,
|
|
||||||
rules: params.rules,
|
|
||||||
created_by: adminUser.id,
|
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
|
||||||
};
|
|
||||||
segments.push(saved);
|
|
||||||
}
|
|
||||||
await saveSegments(params.brand_id, segments);
|
|
||||||
return { success: true, segment: saved };
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: err instanceof Error ? err.message : "Failed to save segment",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteSegment(
|
|
||||||
segmentId: string,
|
|
||||||
brandId: string
|
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
|
||||||
|
|
||||||
await getSession(); const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
|
|
||||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
|
||||||
return { success: false, error: "Not authorized" };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const segments = await loadSegments(brandId);
|
|
||||||
const filtered = segments.filter((s) => s.id !== segmentId);
|
|
||||||
if (filtered.length === segments.length) {
|
|
||||||
return { success: false, error: "Segment not found" };
|
|
||||||
}
|
|
||||||
await saveSegments(brandId, filtered);
|
|
||||||
return { success: true };
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: err instanceof Error ? err.message : "Failed to delete segment",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ const ENTITY_LABELS: Record<string, string> = {
|
|||||||
unknown: "Unknown",
|
unknown: "Unknown",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const analysisLabels = ["Reading your file...", "AI is mapping columns...", "Cleaning and normalizing data...", "Finalizing preview..."];
|
||||||
|
|
||||||
const ALL_FIELDS = ["ignore", "product_name", "price", "description", "product_type", "active", "image_url",
|
const ALL_FIELDS = ["ignore", "product_name", "price", "description", "product_type", "active", "image_url",
|
||||||
"customer_name", "customer_email", "customer_phone", "stop_id", "quantity", "fulfillment", "product_id",
|
"customer_name", "customer_email", "customer_phone", "stop_id", "quantity", "fulfillment", "product_id",
|
||||||
"first_name", "last_name", "full_name", "tags", "email_opt_in", "sms_opt_in", "external_id",
|
"first_name", "last_name", "full_name", "tags", "email_opt_in", "sms_opt_in", "external_id",
|
||||||
@@ -134,7 +136,6 @@ export default function ImportCenterClient({ brandId: initialBrandId, brandName:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stepIndex = ["upload", "analysis", "preview", "import"].indexOf(step);
|
const stepIndex = ["upload", "analysis", "preview", "import"].indexOf(step);
|
||||||
const analysisLabels = ["Reading your file...", "AI is mapping columns...", "Cleaning and normalizing data...", "Finalizing preview..."];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-2xl border border-[var(--admin-border)] overflow-hidden">
|
<div className="bg-white rounded-2xl border border-[var(--admin-border)] overflow-hidden">
|
||||||
|
|||||||
+10
-9
@@ -9,6 +9,16 @@ import { pool } from "@/lib/db";
|
|||||||
|
|
||||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||||
|
|
||||||
|
const featureKeys = [
|
||||||
|
"harvest_reach",
|
||||||
|
"wholesale_portal",
|
||||||
|
"water_log",
|
||||||
|
"ai_tools",
|
||||||
|
"sms_campaigns",
|
||||||
|
"square_sync",
|
||||||
|
"route_trace",
|
||||||
|
] as const;
|
||||||
|
|
||||||
export default async function AdminPage() {
|
export default async function AdminPage() {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
const isStoreEmployee = adminUser?.role === "store_employee";
|
const isStoreEmployee = adminUser?.role === "store_employee";
|
||||||
@@ -62,15 +72,6 @@ export default async function AdminPage() {
|
|||||||
enabledAddons = o.enabledAddons;
|
enabledAddons = o.enabledAddons;
|
||||||
} else {
|
} else {
|
||||||
// Fallback to per-feature flag check (matches prior behavior)
|
// Fallback to per-feature flag check (matches prior behavior)
|
||||||
const featureKeys = [
|
|
||||||
"harvest_reach",
|
|
||||||
"wholesale_portal",
|
|
||||||
"water_log",
|
|
||||||
"ai_tools",
|
|
||||||
"sms_campaigns",
|
|
||||||
"square_sync",
|
|
||||||
"route_trace",
|
|
||||||
] as const;
|
|
||||||
const featureFlags = await Promise.all(
|
const featureFlags = await Promise.all(
|
||||||
featureKeys.map((key) => isFeatureEnabled(dashboardBrandId, key)),
|
featureKeys.map((key) => isFeatureEnabled(dashboardBrandId, key)),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,6 +22,37 @@ type ProductRow = {
|
|||||||
available_until: string | null;
|
available_until: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const baseQuery = (db: Parameters<Parameters<typeof withBrand>[1]>[0]) =>
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: products.id,
|
||||||
|
name: products.name,
|
||||||
|
description: products.description,
|
||||||
|
priceCents: products.priceCents,
|
||||||
|
active: products.active,
|
||||||
|
brandId: products.brandId,
|
||||||
|
brandName: brands.name,
|
||||||
|
firstImageKey: productImages.storageKey,
|
||||||
|
firstImagePosition: productImages.position,
|
||||||
|
})
|
||||||
|
.from(products)
|
||||||
|
.leftJoin(brands, eq(brands.id, products.brandId))
|
||||||
|
// Pull only the lowest-position image per product. The
|
||||||
|
// `position` index on product_images (product_id, position) makes
|
||||||
|
// this cheap; a real-world scale would replace it with a
|
||||||
|
// DISTINCT ON subquery, but for the current catalog size this
|
||||||
|
// join is fine and avoids a second round-trip.
|
||||||
|
.leftJoin(
|
||||||
|
productImages,
|
||||||
|
sql`${productImages.id} = (
|
||||||
|
SELECT id FROM product_images
|
||||||
|
WHERE product_id = ${products.id}
|
||||||
|
ORDER BY position ASC, created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
)`,
|
||||||
|
)
|
||||||
|
.orderBy(asc(products.name));
|
||||||
|
|
||||||
export default async function AdminProductsPage() {
|
export default async function AdminProductsPage() {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
|
|
||||||
@@ -61,37 +92,6 @@ export default async function AdminProductsPage() {
|
|||||||
let productsList: ProductRow[] = [];
|
let productsList: ProductRow[] = [];
|
||||||
let queryError: string | null = null;
|
let queryError: string | null = null;
|
||||||
try {
|
try {
|
||||||
const baseQuery = (db: Parameters<Parameters<typeof withBrand>[1]>[0]) =>
|
|
||||||
db
|
|
||||||
.select({
|
|
||||||
id: products.id,
|
|
||||||
name: products.name,
|
|
||||||
description: products.description,
|
|
||||||
priceCents: products.priceCents,
|
|
||||||
active: products.active,
|
|
||||||
brandId: products.brandId,
|
|
||||||
brandName: brands.name,
|
|
||||||
firstImageKey: productImages.storageKey,
|
|
||||||
firstImagePosition: productImages.position,
|
|
||||||
})
|
|
||||||
.from(products)
|
|
||||||
.leftJoin(brands, eq(brands.id, products.brandId))
|
|
||||||
// Pull only the lowest-position image per product. The
|
|
||||||
// `position` index on product_images (product_id, position) makes
|
|
||||||
// this cheap; a real-world scale would replace it with a
|
|
||||||
// DISTINCT ON subquery, but for the current catalog size this
|
|
||||||
// join is fine and avoids a second round-trip.
|
|
||||||
.leftJoin(
|
|
||||||
productImages,
|
|
||||||
sql`${productImages.id} = (
|
|
||||||
SELECT id FROM product_images
|
|
||||||
WHERE product_id = ${products.id}
|
|
||||||
ORDER BY position ASC, created_at ASC
|
|
||||||
LIMIT 1
|
|
||||||
)`,
|
|
||||||
)
|
|
||||||
.orderBy(asc(products.name));
|
|
||||||
|
|
||||||
const rows = isPlatformAdmin
|
const rows = isPlatformAdmin
|
||||||
? await withPlatformAdmin((db) => baseQuery(db))
|
? await withPlatformAdmin((db) => baseQuery(db))
|
||||||
: brandId
|
: brandId
|
||||||
|
|||||||
@@ -5,6 +5,29 @@ import Link from "next/link";
|
|||||||
import { AdminCard } from "@/components/admin/design-system";
|
import { AdminCard } from "@/components/admin/design-system";
|
||||||
import { setAIProviderSettings } from "@/actions/integrations/ai-providers";
|
import { setAIProviderSettings } from "@/actions/integrations/ai-providers";
|
||||||
|
|
||||||
|
// ── Pure helpers (module scope) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function copyToClipboard(text: string) {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportTypes = [
|
||||||
|
{ value: "orders-by-stop", label: "Orders by Stop" },
|
||||||
|
{ value: "sales-by-product", label: "Sales by Product" },
|
||||||
|
{ value: "fulfillment", label: "Fulfillment" },
|
||||||
|
{ value: "pickup-status", label: "Pickup Status" },
|
||||||
|
{ value: "contact-growth", label: "Contact Growth" },
|
||||||
|
{ value: "campaigns", label: "Campaign Activity" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const exampleQueries = [
|
||||||
|
"Which customers haven't ordered in 45 days?",
|
||||||
|
"What products are trending this month?",
|
||||||
|
"Who are my top customers by revenue?",
|
||||||
|
"Show recent orders from the last 7 days",
|
||||||
|
"Which customers are at risk of churning?",
|
||||||
|
];
|
||||||
|
|
||||||
// ── Header Icon Component ─────────────────────────────────────────────────────
|
// ── Header Icon Component ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function AIHeaderIcon() {
|
function AIHeaderIcon() {
|
||||||
@@ -662,15 +685,6 @@ function ReportExplainerTool({ brandId }: { brandId: string }) {
|
|||||||
const [result, setResult] = useState<{ summary: string; keyInsights: string[]; suggestedActions: string[] } | null>(null);
|
const [result, setResult] = useState<{ summary: string; keyInsights: string[]; suggestedActions: string[] } | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const reportTypes = [
|
|
||||||
{ value: "orders-by-stop", label: "Orders by Stop" },
|
|
||||||
{ value: "sales-by-product", label: "Sales by Product" },
|
|
||||||
{ value: "fulfillment", label: "Fulfillment" },
|
|
||||||
{ value: "pickup-status", label: "Pickup Status" },
|
|
||||||
{ value: "contact-growth", label: "Contact Growth" },
|
|
||||||
{ value: "campaigns", label: "Campaign Activity" },
|
|
||||||
];
|
|
||||||
|
|
||||||
async function handleExplain() {
|
async function handleExplain() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -692,10 +706,6 @@ function ReportExplainerTool({ brandId }: { brandId: string }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyToClipboard(text: string) {
|
|
||||||
navigator.clipboard.writeText(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm rounded-lg p-3" style={{ backgroundColor: 'rgba(0, 0, 0, 0.02)', color: 'var(--admin-text-muted)' }}>
|
<p className="text-sm rounded-lg p-3" style={{ backgroundColor: 'rgba(0, 0, 0, 0.02)', color: 'var(--admin-text-muted)' }}>
|
||||||
@@ -850,10 +860,6 @@ function PricingAdvisorTool({ brandId }: { brandId: string }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyToClipboard(text: string) {
|
|
||||||
navigator.clipboard.writeText(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addTier() { setPriceTiers([...priceTiers, { tier: "", price: "" }]); }
|
function addTier() { setPriceTiers([...priceTiers, { tier: "", price: "" }]); }
|
||||||
function removeTier(i: number) { setPriceTiers(priceTiers.filter((_, idx) => idx !== i)); }
|
function removeTier(i: number) { setPriceTiers(priceTiers.filter((_, idx) => idx !== i)); }
|
||||||
function updateTier(i: number, field: keyof PriceTier, val: string) {
|
function updateTier(i: number, field: keyof PriceTier, val: string) {
|
||||||
@@ -1099,14 +1105,11 @@ function StopBlastAdvisorTool({ brandId }: { brandId: string }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyToClipboard(text: string) {
|
|
||||||
navigator.clipboard.writeText(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm rounded-lg p-3" style={{ backgroundColor: 'rgba(0, 0, 0, 0.02)', color: 'var(--admin-text-muted)' }}>
|
<p className="text-sm rounded-lg p-3" style={{ backgroundColor: 'rgba(0, 0, 0, 0.02)', color: 'var(--admin-text-muted)' }}>
|
||||||
Enter details about the stop you want to send a blast for. The AI will suggest optimal timing, subject lines, content angles, and audience targeting.
|
Enter details about the stop you want to send a blast for.
|
||||||
|
The AI will suggest optimal timing, subject lines, content angles, and audience targeting.
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -1203,14 +1206,6 @@ function CustomerInsightsTool({ brandId }: { brandId: string }) {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const exampleQueries = [
|
|
||||||
"Which customers haven't ordered in 45 days?",
|
|
||||||
"What products are trending this month?",
|
|
||||||
"Who are my top customers by revenue?",
|
|
||||||
"Show recent orders from the last 7 days",
|
|
||||||
"Which customers are at risk of churning?",
|
|
||||||
];
|
|
||||||
|
|
||||||
async function handleAnalyze(nlQuery?: string) {
|
async function handleAnalyze(nlQuery?: string) {
|
||||||
const q = nlQuery ?? query;
|
const q = nlQuery ?? query;
|
||||||
if (!q.trim()) return;
|
if (!q.trim()) return;
|
||||||
@@ -1389,10 +1384,6 @@ function RouteOptimizerTool({ brandId }: { brandId: string }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyToClipboard(text: string) {
|
|
||||||
navigator.clipboard.writeText(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
const validStops = stops.filter((s) => s.name.trim() && s.city.trim() && s.state.trim());
|
const validStops = stops.filter((s) => s.name.trim() && s.city.trim() && s.state.trim());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1621,10 +1612,6 @@ function DemandForecastTool({ brandId }: { brandId: string }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyToClipboard(text: string) {
|
|
||||||
navigator.clipboard.writeText(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addRow() { setHistoricalData([...historicalData, { date: "", quantity_sold: "", stop: "" }]); }
|
function addRow() { setHistoricalData([...historicalData, { date: "", quantity_sold: "", stop: "" }]); }
|
||||||
function removeRow(i: number) { setHistoricalData(historicalData.filter((_, idx) => idx !== i)); }
|
function removeRow(i: number) { setHistoricalData(historicalData.filter((_, idx) => idx !== i)); }
|
||||||
function updateRow(i: number, field: keyof ForecastEntry, val: string) {
|
function updateRow(i: number, field: keyof ForecastEntry, val: string) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +12,20 @@ import {
|
|||||||
} from "@/actions/water-log/admin";
|
} from "@/actions/water-log/admin";
|
||||||
import { AdminButton } from "@/components/admin/design-system";
|
import { AdminButton } from "@/components/admin/design-system";
|
||||||
|
|
||||||
|
async function openPrintWindow(html: string) {
|
||||||
|
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const w = window.open(url, "_blank", "width=700,height=700");
|
||||||
|
if (w) {
|
||||||
|
// Revoke the blob URL once the new window has had time to load the document.
|
||||||
|
w.addEventListener("load", () => URL.revokeObjectURL(url), { once: true });
|
||||||
|
// Safety net: revoke after 60s even if the load event never fires.
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||||
|
} else {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type Headgate = {
|
type Headgate = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -122,6 +136,22 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
|
|||||||
|
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const editDialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = editDialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
if (!dialog.open) dialog.showModal();
|
||||||
|
const onCancel = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setEditHg(null);
|
||||||
|
};
|
||||||
|
dialog.addEventListener("cancel", onCancel);
|
||||||
|
return () => {
|
||||||
|
dialog.removeEventListener("cancel", onCancel);
|
||||||
|
};
|
||||||
|
}, [editHg]);
|
||||||
|
|
||||||
async function handleDelete(hg: Headgate) {
|
async function handleDelete(hg: Headgate) {
|
||||||
if (!window.confirm(`Delete headgate "${hg.name}"? Existing log entries will be preserved.`)) return;
|
if (!window.confirm(`Delete headgate "${hg.name}"? Existing log entries will be preserved.`)) return;
|
||||||
setDeletingId(hg.id);
|
setDeletingId(hg.id);
|
||||||
@@ -171,21 +201,8 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openPrintWindow(html: string) {
|
|
||||||
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const w = window.open(url, "_blank", "width=700,height=700");
|
|
||||||
if (w) {
|
|
||||||
// Revoke the blob URL once the new window has had time to load the document.
|
|
||||||
w.addEventListener("load", () => URL.revokeObjectURL(url), { once: true });
|
|
||||||
// Safety net: revoke after 60s even if the load event never fires.
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
||||||
} else {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<div className="min-h-screen bg-[var(--admin-bg)]">
|
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-white border-b border-[var(--admin-border)] px-6 py-4">
|
<div className="bg-white border-b border-[var(--admin-border)] px-6 py-4">
|
||||||
@@ -371,17 +388,12 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
|
|||||||
|
|
||||||
{/* Edit Modal */}
|
{/* Edit Modal */}
|
||||||
{editHg && (
|
{editHg && (
|
||||||
<div
|
<dialog
|
||||||
role="dialog"
|
ref={editDialogRef}
|
||||||
aria-modal="true"
|
|
||||||
aria-label="Edit Headgate"
|
aria-label="Edit Headgate"
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 m-0 p-0 max-w-none max-h-none w-full h-full backdrop:bg-black/50"
|
||||||
onClick={() => setEditHg(null)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Escape") setEditHg(null);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 overflow-hidden border border-[var(--admin-border)]" onClick={(e) => e.stopPropagation()}>
|
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 overflow-hidden border border-[var(--admin-border)]">
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--admin-border)]">
|
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--admin-border)]">
|
||||||
<p className="font-bold text-[var(--admin-text-primary)]">Edit Headgate</p>
|
<p className="font-bold text-[var(--admin-text-primary)]">Edit Headgate</p>
|
||||||
<button type="button" onClick={() => setEditHg(null)} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)]">
|
<button type="button" onClick={() => setEditHg(null)} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)]">
|
||||||
@@ -461,7 +473,7 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</dialog>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* QR Modal */}
|
{/* QR Modal */}
|
||||||
@@ -475,6 +487,21 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
|
|||||||
function QRModal({ hg, onClose, onRegenerate }: { hg: Headgate; onClose: () => void; onRegenerate: (hg: Headgate) => void }) {
|
function QRModal({ hg, onClose, onRegenerate }: { hg: Headgate; onClose: () => void; onRegenerate: (hg: Headgate) => void }) {
|
||||||
const [tab, setTab] = useState<"preview" | "print" | "download">("preview");
|
const [tab, setTab] = useState<"preview" | "print" | "download">("preview");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
if (!dialog.open) dialog.showModal();
|
||||||
|
const onCancel = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
dialog.addEventListener("cancel", onCancel);
|
||||||
|
return () => {
|
||||||
|
dialog.removeEventListener("cancel", onCancel);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
const code = hg.name.replace(/\s+/g, "-").toUpperCase().slice(0, 6) + "-1";
|
const code = hg.name.replace(/\s+/g, "-").toUpperCase().slice(0, 6) + "-1";
|
||||||
const qrUrl = `/api/water-qr?token=${hg.headgate_token}&size=360`;
|
const qrUrl = `/api/water-qr?token=${hg.headgate_token}&size=360`;
|
||||||
@@ -520,17 +547,12 @@ function QRModal({ hg, onClose, onRegenerate }: { hg: Headgate; onClose: () => v
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<dialog
|
||||||
role="dialog"
|
ref={dialogRef}
|
||||||
aria-modal="true"
|
|
||||||
aria-label="Headgate details"
|
aria-label="Headgate details"
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 m-0 p-0 max-w-none max-h-none w-full h-full backdrop:bg-black/50"
|
||||||
onClick={onClose}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 overflow-hidden border border-[var(--admin-border)]" onClick={(e) => e.stopPropagation()}>
|
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 overflow-hidden border border-[var(--admin-border)]">
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--admin-border)]">
|
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--admin-border)]">
|
||||||
@@ -616,6 +638,6 @@ function QRModal({ hg, onClose, onRegenerate }: { hg: Headgate; onClose: () => v
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -50,6 +50,24 @@ function WholesaleIcon() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STATUS_BADGE_MAP: Record<string, "default" | "success" | "warning" | "danger" | "info"> = {
|
||||||
|
pending: "warning",
|
||||||
|
awaiting_deposit: "info",
|
||||||
|
confirmed: "info",
|
||||||
|
fulfilled: "success",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_BADGE_LABEL: Record<string, string> = {
|
||||||
|
pending: "Pending",
|
||||||
|
awaiting_deposit: "Awaiting Deposit",
|
||||||
|
confirmed: "Confirmed",
|
||||||
|
fulfilled: "Fulfilled",
|
||||||
|
};
|
||||||
|
|
||||||
|
function isValidEmail(e: string) {
|
||||||
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
|
||||||
|
}
|
||||||
|
|
||||||
export default function WholesaleClient({ brandId }: { brandId: string }) {
|
export default function WholesaleClient({ brandId }: { brandId: string }) {
|
||||||
const [tab, setTab] = useState<Tab>("dashboard");
|
const [tab, setTab] = useState<Tab>("dashboard");
|
||||||
const [msg, setMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
|
const [msg, setMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
|
||||||
@@ -868,6 +886,21 @@ function CustomerPricingPanel({ customer, products, onClose, onMsg }: {
|
|||||||
const [overrides, setOverrides] = useState<Record<string, string>>({});
|
const [overrides, setOverrides] = useState<Record<string, string>>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
if (!dialog.open) dialog.showModal();
|
||||||
|
const onCancel = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
dialog.addEventListener("cancel", onCancel);
|
||||||
|
return () => {
|
||||||
|
dialog.removeEventListener("cancel", onCancel);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getWholesaleCustomerPricing(customer.id).then(pricing => {
|
getWholesaleCustomerPricing(customer.id).then(pricing => {
|
||||||
@@ -892,14 +925,10 @@ function CustomerPricingPanel({ customer, products, onClose, onMsg }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<dialog
|
||||||
role="dialog"
|
ref={dialogRef}
|
||||||
aria-modal="true"
|
|
||||||
aria-label="Pricing overrides"
|
aria-label="Pricing overrides"
|
||||||
tabIndex={-1}
|
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 m-0 p-0 max-w-none max-h-none w-full h-full backdrop:bg-black/40"
|
||||||
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
|
|
||||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
|
||||||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
|
||||||
>
|
>
|
||||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col border border-[var(--admin-border)]">
|
<div className="bg-white rounded-2xl shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col border border-[var(--admin-border)]">
|
||||||
<div className="px-6 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
|
<div className="px-6 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
|
||||||
@@ -974,7 +1003,7 @@ function CustomerPricingPanel({ customer, products, onClose, onMsg }: {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -994,6 +1023,21 @@ function PriceSheetModal({
|
|||||||
const [subject, setSubject] = useState(defaultSubject);
|
const [subject, setSubject] = useState(defaultSubject);
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
if (!dialog.open) dialog.showModal();
|
||||||
|
const onCancel = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
dialog.addEventListener("cancel", onCancel);
|
||||||
|
return () => {
|
||||||
|
dialog.removeEventListener("cancel", onCancel);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent) {
|
function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -1003,16 +1047,12 @@ function PriceSheetModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<dialog
|
||||||
role="dialog"
|
ref={dialogRef}
|
||||||
aria-modal="true"
|
|
||||||
aria-label="Send price sheet"
|
aria-label="Send price sheet"
|
||||||
tabIndex={-1}
|
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 px-4 m-0 max-w-none max-h-none w-full h-full backdrop:bg-black/40"
|
||||||
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-lg border border-[var(--admin-border)]" onClick={e => e.stopPropagation()}>
|
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg border border-[var(--admin-border)]">
|
||||||
<div className="px-6 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
|
<div className="px-6 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold text-[var(--admin-text-primary)]">Send Price Sheet</h2>
|
<h2 className="text-lg font-bold text-[var(--admin-text-primary)]">Send Price Sheet</h2>
|
||||||
@@ -1071,7 +1111,7 @@ function PriceSheetModal({
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2303,10 +2343,6 @@ function AddRecipientForm({ onAdd }: { onAdd: (email: string, name: string) => v
|
|||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
function isValidEmail(e: string) {
|
|
||||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAdd() {
|
function handleAdd() {
|
||||||
if (!email.trim()) return;
|
if (!email.trim()) return;
|
||||||
if (!isValidEmail(email.trim())) {
|
if (!isValidEmail(email.trim())) {
|
||||||
@@ -2398,21 +2434,9 @@ function WholesaleLoadingSkeleton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: string }) {
|
function StatusBadge({ status }: { status: string }) {
|
||||||
const map: Record<string, "default" | "success" | "warning" | "danger" | "info"> = {
|
|
||||||
pending: "warning",
|
|
||||||
awaiting_deposit: "info",
|
|
||||||
confirmed: "info",
|
|
||||||
fulfilled: "success",
|
|
||||||
};
|
|
||||||
const label: Record<string, string> = {
|
|
||||||
pending: "Pending",
|
|
||||||
awaiting_deposit: "Awaiting Deposit",
|
|
||||||
confirmed: "Confirmed",
|
|
||||||
fulfilled: "Fulfilled",
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<AdminBadge variant={map[status] ?? "default"}>
|
<AdminBadge variant={STATUS_BADGE_MAP[status] ?? "default"}>
|
||||||
{label[status] ?? status}
|
{STATUS_BADGE_LABEL[status] ?? status}
|
||||||
</AdminBadge>
|
</AdminBadge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,8 @@ import { withDb, withPlatformAdmin } from "@/db/client";
|
|||||||
import { orders } from "@/db/schema/orders";
|
import { orders } from "@/db/schema/orders";
|
||||||
import { customers } from "@/db/schema/customers";
|
import { customers } from "@/db/schema/customers";
|
||||||
|
|
||||||
|
const REPORT_HEADERS = ["id", "customer_name", "customer_email", "status", "total_cents", "placed_at"];
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) {
|
if (!adminUser) {
|
||||||
@@ -74,8 +76,7 @@ export async function GET(req: NextRequest) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (format === "csv") {
|
if (format === "csv") {
|
||||||
const headers = ["id", "customer_name", "customer_email", "status", "total_cents", "placed_at"];
|
const csvRows = [REPORT_HEADERS.join(",")];
|
||||||
const csvRows = [headers.join(",")];
|
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
csvRows.push(
|
csvRows.push(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ function formatTime(iso: string): string {
|
|||||||
return new Date(iso).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
|
return new Date(iso).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const QUICKBOOKS_HEADERS = ["Employee Name", "Date", "Clock In", "Clock Out", "Hours", "Task / Service Item", "Overtime Hours", "Notes"];
|
||||||
|
const PAYROLL_HEADERS = ["Worker Name", "Role", "Days Worked", "Total Hours", "Regular Hours", "Overtime Hours", "Tasks"];
|
||||||
|
const SUMMARY_HEADERS = ["Brand", "Worker Name", "Pay Period Start", "Pay Period End", "Total Hours", "Daily OT Hours", "Weekly OT Hours", "Status"];
|
||||||
|
const DETAILED_HEADERS = [
|
||||||
|
"Worker Name", "Task", "Date", "Clock In", "Clock Out",
|
||||||
|
"Lunch (min)", "Total Minutes", "Hours", "Overtime Hours",
|
||||||
|
"Submitted Via", "Notes", "Brand",
|
||||||
|
];
|
||||||
|
|
||||||
function csvRow(values: (string | number | boolean | null)[]): string {
|
function csvRow(values: (string | number | boolean | null)[]): string {
|
||||||
return values.map(v => `"${String(v ?? "").replace(/"/g, '""')}"`).join(",");
|
return values.map(v => `"${String(v ?? "").replace(/"/g, '""')}"`).join(",");
|
||||||
}
|
}
|
||||||
@@ -128,7 +137,7 @@ export async function GET(req: NextRequest) {
|
|||||||
if (exportType === "quickbooks") {
|
if (exportType === "quickbooks") {
|
||||||
// QuickBooks Time import format
|
// QuickBooks Time import format
|
||||||
// Columns: Employee Name, Date, Clock In, Clock Out, Hours, Task/Service Item, Overtime Hours, Notes
|
// Columns: Employee Name, Date, Clock In, Clock Out, Hours, Task/Service Item, Overtime Hours, Notes
|
||||||
const headers = ["Employee Name", "Date", "Clock In", "Clock Out", "Hours", "Task / Service Item", "Overtime Hours", "Notes"];
|
const headers = QUICKBOOKS_HEADERS;
|
||||||
const rows = allLogs.map((log, i) => {
|
const rows = allLogs.map((log, i) => {
|
||||||
const worker = allWorkers.flat().find(w => w.id === log.worker_id);
|
const worker = allWorkers.flat().find(w => w.id === log.worker_id);
|
||||||
const settings = allSettings.find(s => s && s.brand_id === log.brandId);
|
const settings = allSettings.find(s => s && s.brand_id === log.brandId);
|
||||||
@@ -174,7 +183,7 @@ export async function GET(req: NextRequest) {
|
|||||||
entry.days.add(formatDate(log.clock_in));
|
entry.days.add(formatDate(log.clock_in));
|
||||||
entry.tasks.add(log.task_name);
|
entry.tasks.add(log.task_name);
|
||||||
}
|
}
|
||||||
const headers = ["Worker Name", "Role", "Days Worked", "Total Hours", "Regular Hours", "Overtime Hours", "Tasks"];
|
const headers = PAYROLL_HEADERS;
|
||||||
const rows = [...workerMap.values()].map(e => {
|
const rows = [...workerMap.values()].map(e => {
|
||||||
const totalH = e.totalMin / 60;
|
const totalH = e.totalMin / 60;
|
||||||
const otH = Math.max(0, totalH - 40); // weekly OT at 40h
|
const otH = Math.max(0, totalH - 40); // weekly OT at 40h
|
||||||
@@ -195,7 +204,7 @@ export async function GET(req: NextRequest) {
|
|||||||
else if (exportType === "summary") {
|
else if (exportType === "summary") {
|
||||||
// Pay period summary — one row per worker per pay period
|
// Pay period summary — one row per worker per pay period
|
||||||
const brandName = allSettings[0]?.brand_name ?? "Farm";
|
const brandName = allSettings[0]?.brand_name ?? "Farm";
|
||||||
const headers = ["Brand", "Worker Name", "Pay Period Start", "Pay Period End", "Total Hours", "Daily OT Hours", "Weekly OT Hours", "Status"];
|
const headers = SUMMARY_HEADERS;
|
||||||
const settingsByBrand = new Map<string, NonNullable<typeof allSettings[number]>>();
|
const settingsByBrand = new Map<string, NonNullable<typeof allSettings[number]>>();
|
||||||
for (const s of allSettings) {
|
for (const s of allSettings) {
|
||||||
if (s && s.brand_id) settingsByBrand.set(s.brand_id, s);
|
if (s && s.brand_id) settingsByBrand.set(s.brand_id, s);
|
||||||
@@ -240,11 +249,7 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Detailed audit log
|
// Detailed audit log
|
||||||
const headers = [
|
const headers = DETAILED_HEADERS;
|
||||||
"Worker Name", "Task", "Date", "Clock In", "Clock Out",
|
|
||||||
"Lunch (min)", "Total Minutes", "Hours", "Overtime Hours",
|
|
||||||
"Submitted Via", "Notes", "Brand",
|
|
||||||
];
|
|
||||||
const rows = allLogs.map(log => {
|
const rows = allLogs.map(log => {
|
||||||
const settings = allSettings.find(s => s && s.brand_id === log.brandId);
|
const settings = allSettings.find(s => s && s.brand_id === log.brandId);
|
||||||
const { regularHours, overtimeHours } = settings ? computeOvertime(log, settings) : { regularHours: 0, overtimeHours: 0 };
|
const { regularHours, overtimeHours } = settings ? computeOvertime(log, settings) : { regularHours: 0, overtimeHours: 0 };
|
||||||
|
|||||||
@@ -14,6 +14,23 @@ import type { WaterLogReportRow } from "@/lib/water-log-reporting";
|
|||||||
|
|
||||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||||
|
|
||||||
|
const WATER_LOG_HEADERS = [
|
||||||
|
"When",
|
||||||
|
"Headgate",
|
||||||
|
"User",
|
||||||
|
"Measurement",
|
||||||
|
"Unit",
|
||||||
|
"Total Gallons",
|
||||||
|
"Method",
|
||||||
|
"Notes",
|
||||||
|
"Via",
|
||||||
|
"Photo URL",
|
||||||
|
];
|
||||||
|
|
||||||
|
function esc(s: string | null | undefined) {
|
||||||
|
return `"${(s ?? "").replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) {
|
if (!adminUser) {
|
||||||
@@ -43,21 +60,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
if (format === "csv") {
|
if (format === "csv") {
|
||||||
const headers = [
|
const lines: string[] = [WATER_LOG_HEADERS.join(",")];
|
||||||
"When",
|
|
||||||
"Headgate",
|
|
||||||
"User",
|
|
||||||
"Measurement",
|
|
||||||
"Unit",
|
|
||||||
"Total Gallons",
|
|
||||||
"Method",
|
|
||||||
"Notes",
|
|
||||||
"Via",
|
|
||||||
"Photo URL",
|
|
||||||
];
|
|
||||||
const esc = (s: string | null | undefined) =>
|
|
||||||
`"${(s ?? "").replace(/"/g, '""')}"`;
|
|
||||||
const lines: string[] = [headers.join(",")];
|
|
||||||
for (const e of raw) {
|
for (const e of raw) {
|
||||||
lines.push(
|
lines.push(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCart } from "@/context/CartContext";
|
import { useCart } from "@/context/CartContext";
|
||||||
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
||||||
@@ -88,6 +88,23 @@ export default function CartClient() {
|
|||||||
window.location.href = "/checkout";
|
window.location.href = "/checkout";
|
||||||
}, [cart.length, stopBrandMismatch, hasStopPickupItems, selectedStop, incompatibleItems, setSelectedStop, handleOpenStopPicker]);
|
}, [cart.length, stopBrandMismatch, hasStopPickupItems, selectedStop, incompatibleItems, setSelectedStop, handleOpenStopPicker]);
|
||||||
|
|
||||||
|
const stopPickerDialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showStopPicker) return;
|
||||||
|
const dialog = stopPickerDialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
if (!dialog.open) dialog.showModal();
|
||||||
|
const onCancel = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowStopPicker(false);
|
||||||
|
};
|
||||||
|
dialog.addEventListener("cancel", onCancel);
|
||||||
|
return () => {
|
||||||
|
dialog.removeEventListener("cancel", onCancel);
|
||||||
|
};
|
||||||
|
}, [showStopPicker]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen relative">
|
<div className="min-h-screen relative">
|
||||||
{/* Background */}
|
{/* Background */}
|
||||||
@@ -208,11 +225,10 @@ export default function CartClient() {
|
|||||||
|
|
||||||
{/* Stop picker modal */}
|
{/* Stop picker modal */}
|
||||||
{showStopPicker && (
|
{showStopPicker && (
|
||||||
<div
|
<dialog
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 pointer-events-none"
|
ref={stopPickerDialogRef}
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="stop-picker-title"
|
aria-labelledby="stop-picker-title"
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 m-0 max-w-none max-h-none w-full h-full backdrop:bg-black/60"
|
||||||
>
|
>
|
||||||
<div className="w-full max-w-sm rounded-2xl glass-strong p-6 shadow-2xl pointer-events-auto border border-white/10">
|
<div className="w-full max-w-sm rounded-2xl glass-strong p-6 shadow-2xl pointer-events-auto border border-white/10">
|
||||||
<h3 id="stop-picker-title" className="text-xl font-semibold text-white">Choose Pickup Stop</h3>
|
<h3 id="stop-picker-title" className="text-xl font-semibold text-white">Choose Pickup Stop</h3>
|
||||||
@@ -245,7 +261,7 @@ export default function CartClient() {
|
|||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</dialog>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Cart items */}
|
{/* Cart items */}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { notFound } from "next/navigation";
|
|||||||
import { getTraceChain, getLotIdByNumber } from "@/actions/route-trace/lots";
|
import { getTraceChain, getLotIdByNumber } from "@/actions/route-trace/lots";
|
||||||
import ShareTraceButton from "@/components/route-trace/ShareTraceButton";
|
import ShareTraceButton from "@/components/route-trace/ShareTraceButton";
|
||||||
|
|
||||||
|
const STATUS_ORDER = ["active", "in_transit", "at_shed", "packed", "delivered"];
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: Promise<{ lotNumber: string }> }) {
|
export async function generateMetadata({ params }: { params: Promise<{ lotNumber: string }> }) {
|
||||||
const { lotNumber } = await params;
|
const { lotNumber } = await params;
|
||||||
|
|
||||||
@@ -77,8 +79,7 @@ export default async function TracePage({ params }: { params: Promise<{ lotNumbe
|
|||||||
|
|
||||||
const { lot, events, orders } = result.chain;
|
const { lot, events, orders } = result.chain;
|
||||||
|
|
||||||
const statusOrder = ["active", "in_transit", "at_shed", "packed", "delivered"];
|
const currentStepIndex = STATUS_ORDER.indexOf(lot.status);
|
||||||
const currentStepIndex = statusOrder.indexOf(lot.status);
|
|
||||||
const progressPercent = currentStepIndex >= 0 ? ((currentStepIndex + 1) / 5) * 100 : 0;
|
const progressPercent = currentStepIndex >= 0 ? ((currentStepIndex + 1) / 5) * 100 : 0;
|
||||||
|
|
||||||
const totalQuantity = Number(lot.quantity_lbs ?? 0);
|
const totalQuantity = Number(lot.quantity_lbs ?? 0);
|
||||||
@@ -230,12 +231,12 @@ export default async function TracePage({ params }: { params: Promise<{ lotNumbe
|
|||||||
<div key={i} className="flex-1 flex flex-col items-center gap-1">
|
<div key={i} className="flex-1 flex flex-col items-center gap-1">
|
||||||
<div className={`h-9 w-9 rounded-full flex items-center justify-center text-base transition-all ${
|
<div className={`h-9 w-9 rounded-full flex items-center justify-center text-base transition-all ${
|
||||||
done ? "bg-green-500 text-white shadow-sm" :
|
done ? "bg-green-500 text-white shadow-sm" :
|
||||||
active ? `${STEP_COLORS[statusOrder[i]] ?? "bg-stone-400"} text-white shadow-sm ring-3 ring-white ring-offset-1` :
|
active ? `${STEP_COLORS[STATUS_ORDER[i]] ?? "bg-stone-400"} text-white shadow-sm ring-3 ring-white ring-offset-1` :
|
||||||
"bg-stone-100 text-stone-300"
|
"bg-stone-100 text-stone-300"
|
||||||
}`}>
|
}`}>
|
||||||
{done ? <CheckIcon className="w-3.5 h-3.5 text-white" /> : icon}
|
{done ? <CheckIcon className="w-3.5 h-3.5 text-white" /> : icon}
|
||||||
</div>
|
</div>
|
||||||
<div className={`h-0.5 w-full rounded-full ${done ? "bg-green-400" : active ? (STEP_COLORS[statusOrder[i]] ?? "bg-stone-300") : "bg-stone-100"}`} />
|
<div className={`h-0.5 w-full rounded-full ${done ? "bg-green-400" : active ? (STEP_COLORS[STATUS_ORDER[i]] ?? "bg-stone-300") : "bg-stone-100"}`} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
+10
-10
@@ -13,6 +13,16 @@ import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
|||||||
import BrandStylesProvider from "@/components/storefront/BrandStylesProvider";
|
import BrandStylesProvider from "@/components/storefront/BrandStylesProvider";
|
||||||
import { ScrollReveal, ParallaxLayer, FadeOnScroll } from "@/components/ui/ScrollAnimations";
|
import { ScrollReveal, ParallaxLayer, FadeOnScroll } from "@/components/ui/ScrollAnimations";
|
||||||
import { supabase } from "@/lib/supabase";
|
import { supabase } from "@/lib/supabase";
|
||||||
|
|
||||||
|
function scrollToStops() {
|
||||||
|
document.getElementById("stops")?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}
|
||||||
|
function scrollToStory() {
|
||||||
|
document.getElementById("story")?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}
|
||||||
|
function scrollToProducts() {
|
||||||
|
document.getElementById("products")?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}
|
||||||
import { getBrandSettingsPublic } from "@/actions/brand-settings";
|
import { getBrandSettingsPublic } from "@/actions/brand-settings";
|
||||||
import { gsap } from "gsap";
|
import { gsap } from "gsap";
|
||||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||||
@@ -495,16 +505,6 @@ export default function TuxedoPage() {
|
|||||||
load();
|
load();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function scrollToStops() {
|
|
||||||
document.getElementById("stops")?.scrollIntoView({ behavior: "smooth" });
|
|
||||||
}
|
|
||||||
function scrollToStory() {
|
|
||||||
document.getElementById("story")?.scrollIntoView({ behavior: "smooth" });
|
|
||||||
}
|
|
||||||
function scrollToProducts() {
|
|
||||||
document.getElementById("products")?.scrollIntoView({ behavior: "smooth" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── GSAP SCROLL ANIMATIONS ──────────────────────────────────────────────
|
// ─── GSAP SCROLL ANIMATIONS ──────────────────────────────────────────────
|
||||||
// Now explicitly scoped + resilient guards (no more "invalid scope" warnings)
|
// Now explicitly scoped + resilient guards (no more "invalid scope" warnings)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -22,16 +22,17 @@ type Props = {
|
|||||||
|
|
||||||
type Queue = "past_due" | "today" | "upcoming";
|
type Queue = "past_due" | "today" | "upcoming";
|
||||||
|
|
||||||
|
const STATUS_BADGE_MAP: Record<string, string> = {
|
||||||
|
pending: "bg-yellow-100 text-yellow-700",
|
||||||
|
awaiting_deposit: "bg-purple-100 text-purple-700",
|
||||||
|
confirmed: "bg-blue-100 text-blue-700",
|
||||||
|
fulfilled: "bg-green-100 text-green-700",
|
||||||
|
cancelled: "bg-red-100 text-red-700",
|
||||||
|
};
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: string }) {
|
function StatusBadge({ status }: { status: string }) {
|
||||||
const map: Record<string, string> = {
|
|
||||||
pending: "bg-yellow-100 text-yellow-700",
|
|
||||||
awaiting_deposit: "bg-purple-100 text-purple-700",
|
|
||||||
confirmed: "bg-blue-100 text-blue-700",
|
|
||||||
fulfilled: "bg-green-100 text-green-700",
|
|
||||||
cancelled: "bg-red-100 text-red-700",
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${map[status] ?? "bg-slate-100 text-slate-600"}`}>
|
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_BADGE_MAP[status] ?? "bg-slate-100 text-slate-600"}`}>
|
||||||
{status.replace(/_/g, " ")}
|
{status.replace(/_/g, " ")}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -226,11 +226,11 @@ export default function AIProviderPanel({ brandId }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-4 sm:p-6 space-y-4">
|
<div className="p-4 sm:p-6 space-y-4">
|
||||||
<div>
|
<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
|
API Key
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input aria-label="Input"
|
<input id="fld-3-api-key" aria-label="Input"
|
||||||
type={showKey ? "text" : "password"}
|
type={showKey ? "text" : "password"}
|
||||||
value={apiKey}
|
value={apiKey}
|
||||||
onChange={(e) => setApiKey(e.target.value)}
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
@@ -276,9 +276,9 @@ export default function AIProviderPanel({ brandId }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-4 sm:p-6 space-y-4">
|
<div className="p-4 sm:p-6 space-y-4">
|
||||||
<div>
|
<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">
|
<div className="relative">
|
||||||
<input aria-label="Sk ..."
|
<input id="fld-4-api-key" aria-label="Sk ..."
|
||||||
type={showKey ? "text" : "password"}
|
type={showKey ? "text" : "password"}
|
||||||
value={apiKey}
|
value={apiKey}
|
||||||
onChange={(e) => setApiKey(e.target.value)}
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
|||||||
@@ -4,6 +4,23 @@ import { useState, useCallback } from "react";
|
|||||||
import { createLocation } from "@/actions/locations";
|
import { createLocation } from "@/actions/locations";
|
||||||
import GlassModal from "@/components/admin/GlassModal";
|
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 = {
|
type Props = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
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]
|
[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;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import { usePathname } from "next/navigation";
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { signOutAction } from "@/actions/auth-actions";
|
import { signOutAction } from "@/actions/auth-actions";
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await signOutAction();
|
||||||
|
}
|
||||||
|
|
||||||
type AdminHeaderProps = {
|
type AdminHeaderProps = {
|
||||||
userRole?: string | null;
|
userRole?: string | null;
|
||||||
canManageUsers?: boolean;
|
canManageUsers?: boolean;
|
||||||
@@ -67,10 +71,6 @@ export default function AdminHeader({ userRole, canManageUsers, routeTraceEnable
|
|||||||
const homeHref = isStoreEmployee ? "/admin/wholesale" : "/admin";
|
const homeHref = isStoreEmployee ? "/admin/wholesale" : "/admin";
|
||||||
const homeLabel = isStoreEmployee ? "Pickup" : "Admin";
|
const homeLabel = isStoreEmployee ? "Pickup" : "Admin";
|
||||||
|
|
||||||
async function handleLogout() {
|
|
||||||
await signOutAction();
|
|
||||||
}
|
|
||||||
|
|
||||||
const roleLabel = userRole === "platform_admin" ? "Platform Admin"
|
const roleLabel = userRole === "platform_admin" ? "Platform Admin"
|
||||||
: userRole === "brand_admin" ? "Brand Admin"
|
: userRole === "brand_admin" ? "Brand Admin"
|
||||||
: userRole === "store_employee" ? "Store Employee"
|
: userRole === "store_employee" ? "Store Employee"
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ import { signOutAction } from "@/actions/auth-actions";
|
|||||||
import BrandSelector from "@/components/admin/BrandSelector";
|
import BrandSelector from "@/components/admin/BrandSelector";
|
||||||
import SideNavGroup from "@/components/admin/SideNavGroup";
|
import SideNavGroup from "@/components/admin/SideNavGroup";
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await signOutAction();
|
||||||
|
}
|
||||||
|
|
||||||
// Sidebar tokens used (from src/styles/admin-design-system.css):
|
// Sidebar tokens used (from src/styles/admin-design-system.css):
|
||||||
// --admin-sidebar-bg (#2A2520)
|
// --admin-sidebar-bg (#2A2520)
|
||||||
// --admin-sidebar-text (#B8B4A8)
|
// --admin-sidebar-text (#B8B4A8)
|
||||||
@@ -273,10 +277,6 @@ export default function AdminSidebar({
|
|||||||
[router, mobileOpen, closeMobileMenu, visibleItems],
|
[router, mobileOpen, closeMobileMenu, visibleItems],
|
||||||
);
|
);
|
||||||
|
|
||||||
async function handleLogout() {
|
|
||||||
await signOutAction();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared nav-link renderer. Used by both the desktop sidebar and the mobile
|
// 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.
|
// slide-in panel — they're the same list, just in a different container.
|
||||||
const renderNavLink = (item: NavItemDef, index: number) => {
|
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">
|
<div className="p-4 sm:p-6 space-y-4">
|
||||||
{/* API Key */}
|
{/* API Key */}
|
||||||
<div>
|
<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>
|
API Key <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input aria-label="Input"
|
<input id="fld-1-api-key" aria-label="Input"
|
||||||
type={showApiKey ? "text" : "password"}
|
type={showApiKey ? "text" : "password"}
|
||||||
value={settings.apiKey}
|
value={settings.apiKey}
|
||||||
onChange={(e) => handleChange("apiKey", e.target.value)}
|
onChange={(e) => handleChange("apiKey", e.target.value)}
|
||||||
@@ -417,10 +417,10 @@ export default function AdvancedAIPanel({ brandId }: Props) {
|
|||||||
{/* Organization ID (OpenAI only) */}
|
{/* Organization ID (OpenAI only) */}
|
||||||
{currentProvider.hasOrgId && (
|
{currentProvider.hasOrgId && (
|
||||||
<div>
|
<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>
|
Organization ID <span className="text-stone-400">(optional)</span>
|
||||||
</label>
|
</label>
|
||||||
<input aria-label="Org ..."
|
<input id="fld-2-organization-id" aria-label="Org ..."
|
||||||
type="text"
|
type="text"
|
||||||
value={settings.orgId}
|
value={settings.orgId}
|
||||||
onChange={(e) => handleChange("orgId", e.target.value)}
|
onChange={(e) => handleChange("orgId", e.target.value)}
|
||||||
@@ -445,11 +445,11 @@ export default function AdvancedAIPanel({ brandId }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-4 sm:p-6 space-y-4">
|
<div className="p-4 sm:p-6 space-y-4">
|
||||||
<div>
|
<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>
|
API Key <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input aria-label="Sk ..."
|
<input id="fld-3-api-key" aria-label="Sk ..."
|
||||||
type={showApiKey ? "text" : "password"}
|
type={showApiKey ? "text" : "password"}
|
||||||
value={settings.apiKey}
|
value={settings.apiKey}
|
||||||
onChange={(e) => handleChange("apiKey", e.target.value)}
|
onChange={(e) => handleChange("apiKey", e.target.value)}
|
||||||
@@ -462,10 +462,10 @@ export default function AdvancedAIPanel({ brandId }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
Base URL <span className="text-red-500">*</span>
|
||||||
</label>
|
</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"
|
type="text"
|
||||||
value={settings.customEndpoint}
|
value={settings.customEndpoint}
|
||||||
onChange={(e) => handleChange("customEndpoint", e.target.value)}
|
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">
|
<div className="p-4 sm:p-6 space-y-4">
|
||||||
{/* API Key */}
|
{/* API Key */}
|
||||||
<div>
|
<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">
|
<div className="relative">
|
||||||
<input aria-label="Re ..."
|
<input id="fld-1-api-key" aria-label="Re ..."
|
||||||
type={showSecrets.resendKey ? "text" : "password"}
|
type={showSecrets.resendKey ? "text" : "password"}
|
||||||
value={credentials.resend.api_key}
|
value={credentials.resend.api_key}
|
||||||
onChange={(e) => setCredentials((p) => ({
|
onChange={(e) => setCredentials((p) => ({
|
||||||
@@ -307,9 +307,9 @@ export default function AdvancedIntegrations({ brandId, brands }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
{/* Auth Token */}
|
{/* Auth Token */}
|
||||||
<div>
|
<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">
|
<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"}
|
type={showSecrets.twilioToken ? "text" : "password"}
|
||||||
value={credentials.twilio.auth_token}
|
value={credentials.twilio.auth_token}
|
||||||
onChange={(e) => setCredentials((p) => ({
|
onChange={(e) => setCredentials((p) => ({
|
||||||
|
|||||||
@@ -132,9 +132,9 @@ export default function AdvancedShipping({ brandId }: { brandId: string }) {
|
|||||||
|
|
||||||
{/* API Key / Password */}
|
{/* API Key / Password */}
|
||||||
<div>
|
<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">
|
<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"}
|
type={showApiKey ? "text" : "password"}
|
||||||
value={apiKey}
|
value={apiKey}
|
||||||
onChange={(e) => setApiKey(e.target.value)}
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ import {
|
|||||||
type ConversionFunnel,
|
type ConversionFunnel,
|
||||||
} from "@/actions/analytics";
|
} from "@/actions/analytics";
|
||||||
|
|
||||||
|
function getTrend(avgPrice: number) {
|
||||||
|
if (avgPrice > 10) return "up";
|
||||||
|
if (avgPrice < 5) return "down";
|
||||||
|
return "stable";
|
||||||
|
}
|
||||||
|
|
||||||
interface MetricCardProps {
|
interface MetricCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
value: string | number;
|
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 (
|
return (
|
||||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Top Products</h2>
|
<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 className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<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">
|
<div className="flex gap-2 items-center">
|
||||||
<input aria-label="Color"
|
<input id="fld-1-primary-accent-color" aria-label="Color"
|
||||||
type="color"
|
type="color"
|
||||||
value={brandPrimaryColor}
|
value={brandPrimaryColor}
|
||||||
onChange={(e) => setBrandPrimaryColor(e.target.value)}
|
onChange={(e) => setBrandPrimaryColor(e.target.value)}
|
||||||
className="w-10 h-10 rounded-lg border border-zinc-600 cursor-pointer"
|
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"
|
type="text"
|
||||||
value={brandPrimaryColor}
|
value={brandPrimaryColor}
|
||||||
onChange={(e) => setBrandPrimaryColor(e.target.value)}
|
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>
|
<p className="mt-1 text-xs text-zinc-500">Buttons, badges, links</p>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<div className="flex gap-2 items-center">
|
||||||
<input aria-label="Color"
|
<input id="fld-2-text-color" aria-label="Color"
|
||||||
type="color"
|
type="color"
|
||||||
value={brandTextColor}
|
value={brandTextColor}
|
||||||
onChange={(e) => setBrandTextColor(e.target.value)}
|
onChange={(e) => setBrandTextColor(e.target.value)}
|
||||||
className="w-10 h-10 rounded-lg border border-zinc-600 cursor-pointer"
|
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"
|
type="text"
|
||||||
value={brandTextColor}
|
value={brandTextColor}
|
||||||
onChange={(e) => setBrandTextColor(e.target.value)}
|
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>
|
<p className="mt-1 text-xs text-zinc-500">Headlines, body text</p>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<div className="flex gap-2 items-center">
|
||||||
<input aria-label="Color"
|
<input id="fld-3-background-color" aria-label="Color"
|
||||||
type="color"
|
type="color"
|
||||||
value={brandBgColor}
|
value={brandBgColor}
|
||||||
onChange={(e) => setBrandBgColor(e.target.value)}
|
onChange={(e) => setBrandBgColor(e.target.value)}
|
||||||
className="w-10 h-10 rounded-lg border border-zinc-600 cursor-pointer"
|
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"
|
type="text"
|
||||||
value={brandBgColor}
|
value={brandBgColor}
|
||||||
onChange={(e) => setBrandBgColor(e.target.value)}
|
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>
|
<p className="mt-1 text-xs text-zinc-500">Page background (light mode)</p>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<div className="flex gap-2 items-center">
|
||||||
<input aria-label="Color"
|
<input id="fld-4-secondary-accent-color" aria-label="Color"
|
||||||
type="color"
|
type="color"
|
||||||
value={brandSecondaryColor}
|
value={brandSecondaryColor}
|
||||||
onChange={(e) => setBrandSecondaryColor(e.target.value)}
|
onChange={(e) => setBrandSecondaryColor(e.target.value)}
|
||||||
className="w-10 h-10 rounded-lg border border-zinc-600 cursor-pointer"
|
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"
|
type="text"
|
||||||
value={brandSecondaryColor}
|
value={brandSecondaryColor}
|
||||||
onChange={(e) => setBrandSecondaryColor(e.target.value)}
|
onChange={(e) => setBrandSecondaryColor(e.target.value)}
|
||||||
@@ -617,9 +617,9 @@ function BrandSettingsFormBody({
|
|||||||
|
|
||||||
{collectSalesTax && (
|
{collectSalesTax && (
|
||||||
<div>
|
<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
|
Nexus States
|
||||||
</label>
|
</div>
|
||||||
<p className="mt-1 text-xs text-amber-300/60 mb-2">
|
<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.
|
States where you have tax nexus (physical presence). Stripe Tax will calculate tax for ship orders to these states.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -190,28 +190,24 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [selected, setSelected] = useState(0);
|
const [selected, setSelected] = useState(0);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// Lock body scroll while open; focus the input on next frame.
|
// Native <dialog> handles focus trapping, ESC, and the backdrop.
|
||||||
useEffect(() => {
|
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 raf = requestAnimationFrame(() => inputRef.current?.focus());
|
||||||
|
const onCancel = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
dialog.addEventListener("cancel", onCancel);
|
||||||
return () => {
|
return () => {
|
||||||
cancelAnimationFrame(raf);
|
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]);
|
}, [onClose]);
|
||||||
|
|
||||||
// Filtering
|
// Filtering
|
||||||
@@ -248,24 +244,12 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<dialog
|
||||||
role="dialog"
|
ref={dialogRef}
|
||||||
aria-modal="true"
|
|
||||||
aria-label="Command palette"
|
aria-label="Command palette"
|
||||||
tabIndex={-1}
|
className="m-0 p-0 w-full h-full max-w-none max-h-full bg-transparent"
|
||||||
onClick={onClose}
|
|
||||||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
backgroundColor: "transparent",
|
||||||
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",
|
|
||||||
animation: "cp-fade-in 180ms ease-out",
|
animation: "cp-fade-in 180ms ease-out",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -285,11 +269,24 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
|||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
position: "fixed",
|
||||||
maxWidth: "36rem",
|
inset: 0,
|
||||||
margin: "0 1rem",
|
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)",
|
backgroundColor: "var(--admin-card-bg)",
|
||||||
border: "1px solid var(--admin-border)",
|
border: "1px solid var(--admin-border)",
|
||||||
borderRadius: "var(--admin-radius-lg)",
|
borderRadius: "var(--admin-radius-lg)",
|
||||||
@@ -458,6 +455,7 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
|||||||
↑↓ to navigate · ↵ to select · esc to close
|
↑↓ to navigate · ↵ to select · esc to close
|
||||||
</div>
|
</div>
|
||||||
</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";
|
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 = {
|
type Props = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
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;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
// Success state — show temp password + email status, then "Done" closes.
|
// 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" },
|
{ 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 = {
|
type Props = {
|
||||||
brandId: string | null;
|
brandId: string | null;
|
||||||
brandName: string;
|
brandName: string;
|
||||||
@@ -93,19 +113,6 @@ export default function DashboardClient({
|
|||||||
products: limits.max_products > 0 ? (usage.products / limits.max_products) * 100 : 0,
|
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
|
// "What needs attention" feed — composes a flat list of items that need action
|
||||||
const attentionItems: AttentionItem[] = [];
|
const attentionItems: AttentionItem[] = [];
|
||||||
if (stats.todayOrders === 0) {
|
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 = [];
|
const visibleSections: typeof sections = [];
|
||||||
for (const s of sections) {
|
for (const s of sections) {
|
||||||
if (s.title === "Water Log" && !isWaterLogVisible) continue;
|
if (s.title === "Water Log" && !isWaterLogVisible) continue;
|
||||||
|
|||||||
@@ -4,6 +4,22 @@ import { useState, useCallback } from "react";
|
|||||||
import { updateLocation } from "@/actions/locations";
|
import { updateLocation } from "@/actions/locations";
|
||||||
import GlassModal from "@/components/admin/GlassModal";
|
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 = {
|
export type LocationForEdit = {
|
||||||
id: string;
|
id: string;
|
||||||
brand_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]
|
[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;
|
if (!isOpen || !location) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -8,6 +8,33 @@ import { uploadProductImage } from "@/actions/products/upload-image";
|
|||||||
import { createProduct } from "@/actions/products/create-product";
|
import { createProduct } from "@/actions/products/create-product";
|
||||||
import { AdminInput, AdminTextInput, AdminTextarea, AdminSelect } from "./design-system";
|
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 = {
|
type Props = {
|
||||||
defaultBrandId?: string;
|
defaultBrandId?: string;
|
||||||
brands?: { id: string; name: 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) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -237,7 +237,7 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
|
|||||||
</AdminInput>
|
</AdminInput>
|
||||||
|
|
||||||
<div>
|
<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>
|
<p className="text-xs text-[var(--admin-text-muted)] mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -385,7 +385,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<div className="relative">
|
||||||
<span
|
<span
|
||||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-sm"
|
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>
|
</span>
|
||||||
<input aria-label="Number"
|
<input id="fld-3-price" aria-label="Number"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
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 className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<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">
|
<div className="relative">
|
||||||
<span
|
<span
|
||||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-sm"
|
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>
|
</span>
|
||||||
<input aria-label="Number"
|
<input id="fld-4-discount-amount" aria-label="Number"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -501,11 +501,11 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
|||||||
{/* Customer fields */}
|
{/* Customer fields */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<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>Name</span>
|
||||||
<span className="ha-field-label-required">*</span>
|
<span className="ha-field-label-required">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input aria-label="Text"
|
<input id="fld-5-name" aria-label="Text"
|
||||||
type="text"
|
type="text"
|
||||||
value={customer_name}
|
value={customer_name}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -550,7 +550,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
|||||||
{/* Status & pickup */}
|
{/* Status & pickup */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="ha-field-label mb-2">Status</label>
|
<div className="ha-field-label mb-2">Status</div>
|
||||||
<div className="ha-segment">
|
<div className="ha-segment">
|
||||||
{["pending", "confirmed", "cancelled"].map((s) => (
|
{["pending", "confirmed", "cancelled"].map((s) => (
|
||||||
<button
|
<button
|
||||||
@@ -568,7 +568,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="ha-field-label mb-2">Pickup</label>
|
<div className="ha-field-label mb-2">Pickup</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPickup_complete((v) => !v)}
|
onClick={() => setPickup_complete((v) => !v)}
|
||||||
|
|||||||
@@ -8,6 +8,33 @@ import { uploadProductImage, deleteProductImage } from "@/actions/products/uploa
|
|||||||
import { updateProduct } from "@/actions/products/update-product";
|
import { updateProduct } from "@/actions/products/update-product";
|
||||||
import { AdminInput, AdminTextInput, AdminTextarea, AdminSelect } from "./design-system";
|
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 = {
|
type Brand = {
|
||||||
id: string;
|
id: string;
|
||||||
name: 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() {
|
async function handleSave() {
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
setError("Product name is required.");
|
setError("Product name is required.");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 Image from "next/image";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
@@ -135,24 +135,24 @@ export default function ProductFormModal({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Body scroll lock + escape key
|
// Body scroll lock + escape key
|
||||||
// useEffectEvent so the latest onClose is always called even though
|
// Native <dialog> handles both focus trapping and ESC; we just open
|
||||||
// it's no longer in the effect's dependency array.
|
// it with showModal() and forward the cancel event to onClose so the
|
||||||
const onCloseEffect = useEffectEvent(() => {
|
// parent's React state stays in sync with the native close behavior.
|
||||||
onClose();
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
});
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
const dialog = dialogRef.current;
|
||||||
const original = document.body.style.overflow;
|
if (!dialog) return;
|
||||||
document.body.style.overflow = "hidden";
|
if (!dialog.open) dialog.showModal();
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onCancel = (e: Event) => {
|
||||||
if (e.key === "Escape") onCloseEffect();
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
dialog.addEventListener("cancel", onCancel);
|
||||||
return () => {
|
return () => {
|
||||||
document.body.style.overflow = original;
|
dialog.removeEventListener("cancel", onCancel);
|
||||||
window.removeEventListener("keydown", onKey);
|
|
||||||
};
|
};
|
||||||
}, [open]);
|
}, [open, onClose]);
|
||||||
|
|
||||||
const handleFile = useCallback(
|
const handleFile = useCallback(
|
||||||
async (file: File) => {
|
async (file: File) => {
|
||||||
@@ -232,24 +232,24 @@ export default function ProductFormModal({
|
|||||||
const lockedBrandName = brands.find((b) => b.id === lockedBrandId)?.name ?? lockedBrandId;
|
const lockedBrandName = brands.find((b) => b.id === lockedBrandId)?.name ?? lockedBrandId;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<dialog
|
||||||
className="fixed inset-0 z-[80] flex items-center justify-center p-3 sm:p-6 atelier-backdrop"
|
ref={dialogRef}
|
||||||
onClick={(e) => {
|
aria-label={mode === "add" ? "Add product" : "Edit product"}
|
||||||
if (e.target === e.currentTarget) onClose();
|
className="m-0 p-0 w-full h-full max-w-none max-h-full bg-transparent"
|
||||||
}}
|
style={{ backgroundColor: "transparent" }}
|
||||||
style={{
|
|
||||||
backgroundColor: "color-mix(in srgb, var(--admin-text-primary) 55%, transparent)",
|
|
||||||
backdropFilter: "blur(8px)",
|
|
||||||
WebkitBackdropFilter: "blur(8px)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
className="fixed inset-0 z-[80] flex items-center justify-center p-3 sm:p-6 atelier-backdrop"
|
||||||
aria-modal="true"
|
style={{
|
||||||
aria-label={mode === "add" ? "Add product" : "Edit product"}
|
backgroundColor: "color-mix(in srgb, var(--admin-text-primary) 55%, transparent)",
|
||||||
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)]"
|
backdropFilter: "blur(8px)",
|
||||||
style={{ boxShadow: "0 30px 80px -20px color-mix(in srgb, var(--admin-text-primary) 45%, transparent)" }}
|
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 */}
|
{/* grain overlay */}
|
||||||
<div className="atelier-grain" aria-hidden />
|
<div className="atelier-grain" aria-hidden />
|
||||||
|
|
||||||
@@ -719,7 +719,8 @@ export default function ProductFormModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>
|
||||||
|
</dialog>,
|
||||||
document.body
|
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({
|
export default function ProductsClient({
|
||||||
products,
|
products,
|
||||||
brandId,
|
brandId,
|
||||||
@@ -75,33 +102,6 @@ export default function ProductsClient({
|
|||||||
const activeCount = products.filter((p) => p.active).length;
|
const activeCount = products.filter((p) => p.active).length;
|
||||||
const inactiveCount = 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(
|
const handleUploadImage = useCallback(
|
||||||
async (file: File): Promise<{ success: boolean; imageUrl?: string; error?: string }> => {
|
async (file: File): Promise<{ success: boolean; imageUrl?: string; error?: string }> => {
|
||||||
const validTypes = ["image/png", "image/jpeg", "image/webp"];
|
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 [menuPos, setMenuPos] = useState<{ top: number; left: number; width: number } | null>(null);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const buttonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
const buttonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||||
|
const popupDialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
@@ -389,6 +390,21 @@ function TableView({
|
|||||||
init();
|
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
|
// Track the previous deleteConfirm value so we can adjust menuPos
|
||||||
// inline during render when the prop changes — avoids a stale frame
|
// inline during render when the prop changes — avoids a stale frame
|
||||||
// between the prop change and the effect running. Use a lazy
|
// between the prop change and the effect running. Use a lazy
|
||||||
@@ -540,12 +556,11 @@ function TableView({
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[60]"
|
className="fixed inset-0 z-[60]"
|
||||||
onClick={onDeleteCancel}
|
|
||||||
/>
|
/>
|
||||||
<div
|
<dialog
|
||||||
role="dialog"
|
ref={popupDialogRef}
|
||||||
aria-label="Confirm delete"
|
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%)" }}
|
style={{ top: menuPos.top, left: menuPos.left, transform: "translateX(-100%)" }}
|
||||||
>
|
>
|
||||||
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">
|
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">
|
||||||
@@ -571,7 +586,7 @@ function TableView({
|
|||||||
{deletingId === openProduct.id ? "..." : "Delete"}
|
{deletingId === openProduct.id ? "..." : "Delete"}
|
||||||
</AdminButton>
|
</AdminButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</dialog>
|
||||||
</>,
|
</>,
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { enqueueAction } from "@/lib/offline/queue";
|
import { enqueueAction } from "@/lib/offline/queue";
|
||||||
import { syncPending } from "@/lib/offline/sync";
|
import { syncPending } from "@/lib/offline/sync";
|
||||||
import { dispatchClientAction } from "@/actions/offline-dispatcher";
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -78,18 +95,20 @@ export function StockAdjustButton({ productId, currentStock }: StockAdjustButton
|
|||||||
±
|
±
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{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)" }}>
|
<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="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="fixed inset-0 z-50 flex items-end justify-center backdrop-blur-sm" style={{ backgroundColor: "rgba(0,0,0,0.4)" }}>
|
||||||
<div className="w-12 h-1.5 rounded-full mx-auto mb-4" style={{ backgroundColor: "var(--color-surface-3)" }} />
|
<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)" }}>
|
||||||
<h2 className="text-h1 font-display" style={{ fontWeight: 600 }}>Adjust stock</h2>
|
<div className="w-12 h-1.5 rounded-full mx-auto mb-4" style={{ backgroundColor: "var(--color-surface-3)" }} />
|
||||||
<div className="mt-6 flex items-center justify-center gap-6">
|
<h2 className="text-h1 font-display" style={{ fontWeight: 600 }}>Adjust stock</h2>
|
||||||
<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="mt-6 flex items-center justify-center gap-6">
|
||||||
<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-surface-2)", fontSize: "32px", fontWeight: 700 }}>−</button>
|
||||||
<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 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>
|
</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>
|
||||||
</div>
|
</dialog>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
|
|
||||||
// One-color outline icons
|
// One-color outline icons
|
||||||
@@ -234,6 +234,23 @@ export default function FsmaReportModal({ brandId }: { brandId: string }) {
|
|||||||
setOpen(false);
|
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 filteredLots = data?.lots.filter((lot) => {
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
!search ||
|
!search ||
|
||||||
@@ -255,26 +272,26 @@ export default function FsmaReportModal({ brandId }: { brandId: string }) {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<dialog
|
||||||
role="dialog"
|
ref={dialogRef}
|
||||||
aria-modal="true"
|
|
||||||
aria-label="FSMA report"
|
aria-label="FSMA report"
|
||||||
tabIndex={-1}
|
className="m-0 p-0 max-w-none max-h-none w-full h-full bg-transparent"
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/30"
|
style={{ backgroundColor: "transparent" }}
|
||||||
onClick={(e) => e.target === e.currentTarget && setOpen(false)}
|
|
||||||
onKeyDown={(e) => { if (e.key === "Escape") setOpen(false); }}
|
|
||||||
style={{ backdropFilter: "blur(4px)" }}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0"
|
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
style={{
|
style={{ backdropFilter: "blur(4px)" }}
|
||||||
background: "linear-gradient(180deg, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.2) 100%)",
|
>
|
||||||
backdropFilter: "blur(60px) saturate(180%)",
|
<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 */}
|
{/* Modal card */}
|
||||||
<div
|
<div
|
||||||
className="relative w-full max-w-5xl rounded-2xl max-h-[90vh] flex flex-col"
|
className="relative w-full max-w-5xl rounded-2xl max-h-[90vh] flex flex-col"
|
||||||
style={{
|
style={{
|
||||||
background: "rgba(255, 255, 255, 0.92)",
|
background: "rgba(255, 255, 255, 0.92)",
|
||||||
@@ -612,7 +629,8 @@ export default function FsmaReportModal({ brandId }: { brandId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</dialog>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user