Admin dashboard: fix stats waterfall, redesign layout, add animations
Deploy to route.crispygoat.com / deploy (push) Failing after 3m13s
Deploy to route.crispygoat.com / deploy (push) Failing after 3m13s
- Fetch dashboard stats server-side in admin/page.tsx to eliminate client-side useEffect waterfall and the '—' placeholder flash - Pass pre-fetched stats as props to DashboardClient - Lazy-load UpgradePlanModal via next/dynamic (only loads when needed) - Redesign stats cards with compact layout, smaller icons, Fraunces serif values - Improve Quick Actions + Usage row: side-by-side on desktop, stacked mobile - Clean up Recent Orders as a proper list with icon/name/time/amount/badge - Add staggered fade-up entrance animations (0/60/120/180/240ms delays) - Consolidate loading.tsx skeleton to match actual dashboard structure - Mobile-responsive: 2-col stats on mobile, stacked usage, collapsible header
This commit is contained in:
@@ -47,3 +47,4 @@ playwright-report/
|
|||||||
.mcp.json
|
.mcp.json
|
||||||
.env*
|
.env*
|
||||||
public/videos/tuxedo-hero.mp4
|
public/videos/tuxedo-hero.mp4
|
||||||
|
.neon
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
-- Migration 003: Batch insert RPCs for tour stop / location seeding
|
||||||
|
-- Required by db/seeds/2026-tuxedo-tour-stops.sql and scripts/import-tuxedo-stops.ts
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- admin_create_locations_batch: insert or update locations from tour seed
|
||||||
|
-- Payload shape: { name, address, city, state, zip, phone, contact_name, contact_email, notes, active }[]
|
||||||
|
CREATE OR REPLACE FUNCTION admin_create_locations_batch(p_brand_id UUID, p_locations JSONB)
|
||||||
|
RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_loc JSONB;
|
||||||
|
BEGIN
|
||||||
|
FOR v_loc IN SELECT * FROM jsonb_array_elements(p_locations)
|
||||||
|
LOOP
|
||||||
|
INSERT INTO locations (
|
||||||
|
brand_id, name, address, city, state, zip,
|
||||||
|
phone, contact_name, contact_email, notes, active
|
||||||
|
) VALUES (
|
||||||
|
p_brand_id,
|
||||||
|
v_loc->>'name',
|
||||||
|
v_loc->>'address',
|
||||||
|
v_loc->>'city',
|
||||||
|
v_loc->>'state',
|
||||||
|
NULLIF(v_loc->>'zip', '')::TEXT,
|
||||||
|
v_loc->>'phone',
|
||||||
|
v_loc->>'contact_name',
|
||||||
|
v_loc->>'contact_email',
|
||||||
|
v_loc->>'notes',
|
||||||
|
COALESCE((v_loc->>'active')::BOOLEAN, true)
|
||||||
|
)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- admin_create_stops_batch: insert stops from tour seed
|
||||||
|
-- Payload shape: { city, state, location, date, time, address, zip, cutoff_time, active, notes }[]
|
||||||
|
-- date format: '2026-07-22 00:00:00+00' — cast to DATE
|
||||||
|
CREATE OR REPLACE FUNCTION admin_create_stops_batch(p_brand_id UUID, p_stops JSONB)
|
||||||
|
RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_stop JSONB;
|
||||||
|
BEGIN
|
||||||
|
FOR v_stop IN SELECT * FROM jsonb_array_elements(p_stops)
|
||||||
|
LOOP
|
||||||
|
INSERT INTO stops (
|
||||||
|
brand_id, name, location, address, city, state, zip,
|
||||||
|
date, "time", cutoff_date, status, is_public, notes
|
||||||
|
) VALUES (
|
||||||
|
p_brand_id,
|
||||||
|
COALESCE(NULLIF(v_stop->>'name', ''), (v_stop->>'location')::TEXT || ' - ' || (v_stop->>'city')::TEXT || ', ' || (v_stop->>'state')::TEXT),
|
||||||
|
v_stop->>'location',
|
||||||
|
v_stop->>'address',
|
||||||
|
v_stop->>'city',
|
||||||
|
v_stop->>'state',
|
||||||
|
NULLIF(v_stop->>'zip', '')::TEXT,
|
||||||
|
CASE
|
||||||
|
WHEN v_stop->>'date' IS NULL THEN NULL
|
||||||
|
ELSE LEFT(v_stop->>'date', 10)::DATE
|
||||||
|
END,
|
||||||
|
v_stop->>'time',
|
||||||
|
NULLIF(v_stop->>'cutoff_time', '')::DATE,
|
||||||
|
'active',
|
||||||
|
COALESCE((v_stop->>'active')::BOOLEAN, true),
|
||||||
|
v_stop->>'notes'
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
File diff suppressed because one or more lines are too long
@@ -12,6 +12,7 @@
|
|||||||
"migrate:one": "node scripts/migrate.js",
|
"migrate:one": "node scripts/migrate.js",
|
||||||
"db:migrate": "node scripts/migrate.js",
|
"db:migrate": "node scripts/migrate.js",
|
||||||
"db:seed": "tsx db/seed.ts",
|
"db:seed": "tsx db/seed.ts",
|
||||||
|
"db:seed:tour": "node scripts/seed-tuxedo-2026.js",
|
||||||
"db:reset": "node scripts/db-reset.js",
|
"db:reset": "node scripts/db-reset.js",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"type-check": "npx tsc --noEmit",
|
"type-check": "npx tsc --noEmit",
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
"@google/generative-ai": "^0.24.1",
|
"@google/generative-ai": "^0.24.1",
|
||||||
"@gsap/react": "^2.1.2",
|
"@gsap/react": "^2.1.2",
|
||||||
"@neondatabase/auth": "^0.4.2-beta",
|
"@neondatabase/auth": "^0.4.2-beta",
|
||||||
|
"@neondatabase/serverless": "^1.1.0",
|
||||||
"@sentry/nextjs": "^10.55.0",
|
"@sentry/nextjs": "^10.55.0",
|
||||||
"@stripe/react-stripe-js": "^6.6.0",
|
"@stripe/react-stripe-js": "^6.6.0",
|
||||||
"@stripe/stripe-js": "^9.7.0",
|
"@stripe/stripe-js": "^9.7.0",
|
||||||
|
|||||||
@@ -0,0 +1,448 @@
|
|||||||
|
// dotenv load temporarily disabled so shell-provided DATABASE_URL wins for this run
|
||||||
|
// import { config } from "dotenv";
|
||||||
|
// config({ path: ".env.local" });
|
||||||
|
import { Pool } from "pg";
|
||||||
|
import ExcelJS from "exceljs";
|
||||||
|
import * as fs from "fs";
|
||||||
|
|
||||||
|
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||||
|
const YEAR = 2026;
|
||||||
|
|
||||||
|
const MONTH_MAP: Record<string, string> = {
|
||||||
|
Jan: "01", Feb: "02", Mar: "03", Apr: "04", May: "05", Jun: "06",
|
||||||
|
Jul: "07", Aug: "08", Sep: "09", Oct: "10", Nov: "11", Dec: "12",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ParsedStop {
|
||||||
|
week: string;
|
||||||
|
region: string;
|
||||||
|
date: string;
|
||||||
|
day: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
location: string;
|
||||||
|
time: string;
|
||||||
|
timeRange: string;
|
||||||
|
truck: string;
|
||||||
|
statusText: string;
|
||||||
|
notes: string;
|
||||||
|
address: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
contact: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseExcelDate(s: unknown): string | null {
|
||||||
|
if (!s) return null;
|
||||||
|
const m = /^([A-Za-z]{3})\s+(\d{1,2})$/.exec(String(s).trim());
|
||||||
|
if (!m) return null;
|
||||||
|
const mm = MONTH_MAP[m[1]];
|
||||||
|
if (!mm) return null;
|
||||||
|
return `${YEAR}-${mm}-${parseInt(m[2], 10).toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimeRange(s: unknown): string {
|
||||||
|
if (!s) return "";
|
||||||
|
let c = String(s).replace(/[–—]/g, "-").replace(/\s+/g, " ").trim();
|
||||||
|
const m = /^(\d{1,2}:\d{2}\s*[AP]M)/i.exec(c);
|
||||||
|
// Prefer full range for display (e.g. "10:00 AM – 1:00 PM"); fall back to start only
|
||||||
|
return c || (m ? m[1].toUpperCase().replace(" ", " ") : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitCityState(s: unknown): [string, string] {
|
||||||
|
if (!s) return ["", ""];
|
||||||
|
const parts = String(s).split(",").map((p) => p.trim());
|
||||||
|
if (parts.length === 1) return [parts[0], ""];
|
||||||
|
return [parts[0], parts[1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWeekHeader(cells: string[]): boolean {
|
||||||
|
return /^Wk\s/i.test(cells[0] || "") && (cells[3] || "").trim() === "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOffRow(cells: string[]): boolean {
|
||||||
|
const d = (cells[3] || "").trim();
|
||||||
|
return /OFF|Cross-?Dock/i.test(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDataRow(cells: string[]): boolean {
|
||||||
|
const day = (cells[3] || "").trim();
|
||||||
|
const city = (cells[4] || "").trim();
|
||||||
|
return !!(day && city && city.includes(","));
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(s: string): string {
|
||||||
|
let out = (s || "").toLowerCase();
|
||||||
|
out = out.replace(/[^a-z0-9]+/g, "-");
|
||||||
|
return out.replace(/^-+|-+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStops(xlsxPath: string): Promise<{ stops: ParsedStop[]; skipped: Record<string, number>; dirCount: number }> {
|
||||||
|
const wb = new ExcelJS.Workbook();
|
||||||
|
await wb.xlsx.readFile(xlsxPath);
|
||||||
|
|
||||||
|
const schedule = wb.getWorksheet("Full Schedule");
|
||||||
|
const directory = wb.getWorksheet("Stop Directory");
|
||||||
|
if (!schedule) throw new Error("Missing 'Full Schedule' sheet");
|
||||||
|
|
||||||
|
// Build directory lookup for address enrichment: "T1|host lower" -> info
|
||||||
|
const dirMap = new Map<string, { address: string | null; phone: string | null; contact: string | null; state: string }>();
|
||||||
|
if (directory) {
|
||||||
|
for (let r = 2; r <= directory.rowCount; r++) {
|
||||||
|
const row = directory.getRow(r);
|
||||||
|
const truck = String(row.getCell(1).value || "").trim();
|
||||||
|
const host = String(row.getCell(4).value || "").trim().toLowerCase();
|
||||||
|
if (!truck || !host) continue;
|
||||||
|
dirMap.set(`${truck}|${host}`, {
|
||||||
|
address: String(row.getCell(5).value || "").trim() || null,
|
||||||
|
phone: String(row.getCell(6).value || "").trim() || null,
|
||||||
|
contact: String(row.getCell(7).value || "").trim() || null,
|
||||||
|
state: String(row.getCell(3).value || "").trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stops: ParsedStop[] = [];
|
||||||
|
const skipped: Record<string, number> = { weekHeader: 0, off: 0, invalid: 0, noDate: 0 };
|
||||||
|
|
||||||
|
for (let r = 4; r <= schedule.rowCount; r++) {
|
||||||
|
const row = schedule.getRow(r);
|
||||||
|
const cells = Array.from({ length: 10 }, (_, i) => String(row.getCell(i + 1).value ?? "").trim());
|
||||||
|
|
||||||
|
if (isWeekHeader(cells)) {
|
||||||
|
skipped.weekHeader++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isOffRow(cells)) {
|
||||||
|
skipped.off++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isDataRow(cells)) {
|
||||||
|
skipped.invalid++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [wk, region, dateText, day, cityState, host, time, truck, status, notes] = cells;
|
||||||
|
const dateIso = parseExcelDate(dateText);
|
||||||
|
if (!dateIso) {
|
||||||
|
skipped.noDate++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [city, state] = splitCityState(cityState);
|
||||||
|
if (!city) {
|
||||||
|
skipped.invalid++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${truck}|${host.toLowerCase()}`;
|
||||||
|
const d = dirMap.get(key);
|
||||||
|
|
||||||
|
stops.push({
|
||||||
|
week: wk,
|
||||||
|
region,
|
||||||
|
date: dateIso,
|
||||||
|
day,
|
||||||
|
city,
|
||||||
|
state: state || (d?.state || ""),
|
||||||
|
location: host,
|
||||||
|
time: parseTimeRange(time),
|
||||||
|
timeRange: time,
|
||||||
|
truck,
|
||||||
|
statusText: status,
|
||||||
|
notes: notes || "",
|
||||||
|
address: d?.address || null,
|
||||||
|
phone: d?.phone || null,
|
||||||
|
contact: d?.contact || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { stops, skipped, dirCount: dirMap.size, locations: loadLocations(directory) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParsedLocation = {
|
||||||
|
name: string;
|
||||||
|
address: string | null;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
phone: string | null;
|
||||||
|
contactName: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadLocations(directory: ExcelJS.Worksheet | undefined): ParsedLocation[] {
|
||||||
|
if (!directory) return [];
|
||||||
|
const byKey = new Map<string, ParsedLocation>();
|
||||||
|
for (let r = 2; r <= directory.rowCount; r++) {
|
||||||
|
const row = directory.getRow(r);
|
||||||
|
const truck = String(row.getCell(1).value || "").trim();
|
||||||
|
const city = String(row.getCell(2).value || "").trim();
|
||||||
|
const state = String(row.getCell(3).value || "").trim();
|
||||||
|
const host = String(row.getCell(4).value || "").trim();
|
||||||
|
const address = String(row.getCell(5).value || "").trim() || null;
|
||||||
|
let phone = String(row.getCell(6).value || "").trim() || null;
|
||||||
|
const contact = String(row.getCell(7).value || "").trim() || null;
|
||||||
|
// notes column is 12
|
||||||
|
let notes = String(row.getCell(12).value || "").trim() || null;
|
||||||
|
|
||||||
|
if (!host || !city) continue;
|
||||||
|
|
||||||
|
// If phone field contains TBD email, move it to notes
|
||||||
|
if (phone && /@/.test(phone) && (!notes || notes.length < 10)) {
|
||||||
|
notes = phone;
|
||||||
|
phone = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${host.toLowerCase()}|${city.toLowerCase()}|${state.toLowerCase()}`;
|
||||||
|
if (!byKey.has(key)) {
|
||||||
|
byKey.set(key, {
|
||||||
|
name: host,
|
||||||
|
address,
|
||||||
|
city,
|
||||||
|
state,
|
||||||
|
phone,
|
||||||
|
contactName: contact || null,
|
||||||
|
notes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(byKey.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLocationRpcRow(l: ParsedLocation) {
|
||||||
|
return {
|
||||||
|
name: l.name,
|
||||||
|
address: l.address,
|
||||||
|
city: l.city,
|
||||||
|
state: l.state,
|
||||||
|
zip: null,
|
||||||
|
phone: l.phone,
|
||||||
|
contact_name: l.contactName,
|
||||||
|
contact_email: null,
|
||||||
|
notes: l.notes,
|
||||||
|
active: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRpcRow(s: ParsedStop) {
|
||||||
|
// Date format matches the python seed script expectation for the RPC
|
||||||
|
const dateWithTz = `${s.date} 00:00:00+00`;
|
||||||
|
const combinedNotes = [
|
||||||
|
s.notes,
|
||||||
|
s.truck ? `Truck: ${s.truck}` : "",
|
||||||
|
s.statusText ? `Status: ${s.statusText}` : "",
|
||||||
|
s.phone ? `Phone: ${s.phone}` : "",
|
||||||
|
s.contact ? `Contact: ${s.contact}` : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" | ");
|
||||||
|
|
||||||
|
return {
|
||||||
|
city: s.city,
|
||||||
|
state: s.state,
|
||||||
|
location: s.location,
|
||||||
|
date: dateWithTz,
|
||||||
|
time: s.time || s.timeRange || "",
|
||||||
|
address: s.address,
|
||||||
|
zip: null,
|
||||||
|
cutoff_time: null,
|
||||||
|
// active=true makes them visible on the public /tuxedo/stops storefront immediately
|
||||||
|
active: true,
|
||||||
|
// forward notes when the RPC supports it (enriched with truck/status/contact)
|
||||||
|
notes: combinedNotes || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const dryRun = args.includes("--dry-run") || args.includes("-n");
|
||||||
|
let xlsxPath = "./Tuxedo_Corn_2026_Tour_Schedule-3.xlsx";
|
||||||
|
const xArg = args.findIndex((a) => a === "--xlsx" || a === "-x");
|
||||||
|
if (xArg !== -1 && args[xArg + 1]) xlsxPath = args[xArg + 1];
|
||||||
|
const doClean = !args.includes("--no-clean");
|
||||||
|
const emitSql = args.includes("--emit-sql") || args.includes("--sql");
|
||||||
|
|
||||||
|
console.log(`[import-tuxedo-stops] xlsx=${xlsxPath} dryRun=${dryRun} clean=${doClean} emitSql=${emitSql}`);
|
||||||
|
|
||||||
|
const { stops, skipped, dirCount, locations: parsedLocations } = await loadStops(xlsxPath);
|
||||||
|
|
||||||
|
console.log(`\nParsed ${stops.length} stops + ${parsedLocations.length} unique locations`);
|
||||||
|
console.log(`Skipped: week-headers=${skipped.weekHeader}, off/cross-dock=${skipped.off}, invalid=${skipped.invalid}, no-date=${skipped.noDate}`);
|
||||||
|
console.log(`Stop Directory raw entries: ${dirCount}\n`);
|
||||||
|
|
||||||
|
if (!stops.length) {
|
||||||
|
console.error("No stops to insert. Check the xlsx path and filters.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show samples
|
||||||
|
console.log("Sample (first 3):");
|
||||||
|
stops.slice(0, 3).forEach((s) => {
|
||||||
|
console.log(` ${s.date} ${s.time.padEnd(10)} | ${s.city}, ${s.state} | ${s.location.slice(0, 32).padEnd(32)} | ${s.truck} | ${s.statusText}`);
|
||||||
|
if (s.address) console.log(` addr: ${s.address}`);
|
||||||
|
});
|
||||||
|
console.log("\nSample (last 3):");
|
||||||
|
stops.slice(-3).forEach((s) => {
|
||||||
|
console.log(` ${s.date} ${s.time.padEnd(10)} | ${s.city}, ${s.state} | ${s.location.slice(0, 32).padEnd(32)} | ${s.truck} | ${s.statusText}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// By week/region/truck
|
||||||
|
const byWeek: Record<string, number> = {};
|
||||||
|
const byRegion: Record<string, number> = {};
|
||||||
|
const byTruck: Record<string, number> = {};
|
||||||
|
for (const s of stops) {
|
||||||
|
byWeek[s.week] = (byWeek[s.week] || 0) + 1;
|
||||||
|
byRegion[s.region] = (byRegion[s.region] || 0) + 1;
|
||||||
|
byTruck[s.truck] = (byTruck[s.truck] || 0) + 1;
|
||||||
|
}
|
||||||
|
console.log("\nBy week:", Object.fromEntries(Object.entries(byWeek).sort(([a], [b]) => Number(a) - Number(b))));
|
||||||
|
console.log("By region:", byRegion);
|
||||||
|
console.log("By truck:", byTruck);
|
||||||
|
|
||||||
|
const dates = [...stops.map((s) => s.date)].sort();
|
||||||
|
console.log(`\nDate range: ${dates[0]} → ${dates[dates.length - 1]}`);
|
||||||
|
|
||||||
|
if (emitSql) {
|
||||||
|
const BATCH = 50;
|
||||||
|
let sql = `-- Tuxedo Corn 2026 Tour Data (from ${xlsxPath})\n`;
|
||||||
|
sql += `-- Stops: ${stops.length} | Locations: ${parsedLocations.length}\n`;
|
||||||
|
sql += `-- Preferred: npx tsx scripts/import-tuxedo-stops.ts\n`;
|
||||||
|
sql += `-- Manual apply: psql "$DATABASE_URL" -f db/seeds/2026-tuxedo-tour-stops.sql\n`;
|
||||||
|
sql += `-- supabase db query --linked --file db/seeds/2026-tuxedo-tour-stops.sql\n\n`;
|
||||||
|
sql += `BEGIN;\n\n`;
|
||||||
|
|
||||||
|
// Locations first (master directory)
|
||||||
|
if (parsedLocations.length > 0) {
|
||||||
|
sql += `-- ===== LOCATIONS (Stop Directory master records) =====\n`;
|
||||||
|
sql += `DELETE FROM locations WHERE brand_id = '${TUXEDO_BRAND_ID}';\n\n`;
|
||||||
|
const locPayload = parsedLocations.map(toLocationRpcRow);
|
||||||
|
const locJson = JSON.stringify(locPayload);
|
||||||
|
sql += `-- ${parsedLocations.length} unique locations from Stop Directory\n`;
|
||||||
|
sql += `SELECT admin_create_locations_batch('${TUXEDO_BRAND_ID}'::uuid, $$${locJson}$$::jsonb);\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stops (dated instances)
|
||||||
|
sql += `-- ===== STOPS (2026 tour schedule) =====\n`;
|
||||||
|
sql += `-- Clear prior 2026 tour stops for brand (date-scoped)\n`;
|
||||||
|
sql += `DELETE FROM stops WHERE brand_id = '${TUXEDO_BRAND_ID}' AND date >= '2026-07-01' AND date <= '2026-09-30';\n\n`;
|
||||||
|
for (let i = 0; i < stops.length; i += BATCH) {
|
||||||
|
const batchStops = stops.slice(i, i + BATCH);
|
||||||
|
const payload = batchStops.map(toRpcRow);
|
||||||
|
const payloadJson = JSON.stringify(payload);
|
||||||
|
const bnum = Math.floor(i / BATCH) + 1;
|
||||||
|
sql += `-- stops batch ${bnum}/${Math.ceil(stops.length / BATCH)} (${payload.length})\n`;
|
||||||
|
sql += `SELECT admin_create_stops_batch('${TUXEDO_BRAND_ID}'::uuid, $$${payloadJson}$$::jsonb);\n\n`;
|
||||||
|
}
|
||||||
|
sql += `-- Make stops visible (RPC inserts as draft + active=true in payload)\n`;
|
||||||
|
sql += `UPDATE stops SET status = 'active' WHERE brand_id = '${TUXEDO_BRAND_ID}' AND date >= '2026-07-01' AND date <= '2026-09-30';\n\n`;
|
||||||
|
sql += `COMMIT;\n`;
|
||||||
|
const outPath = "db/seeds/2026-tuxedo-tour-stops.sql";
|
||||||
|
fs.writeFileSync(outPath, sql, "utf8");
|
||||||
|
console.log(`\nWrote ${outPath} (locations: ${parsedLocations.length}, stops: ${stops.length}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
const stopBatches = Math.ceil(stops.length / 50);
|
||||||
|
console.log(`\n[DRY RUN] Would load for brand:\n - ${parsedLocations.length} locations (full replace)\n - DELETE date-scoped 2026 stops then INSERT ${stops.length} stops in ${stopBatches} batch(es)\n via admin_*_batch RPCs.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString:
|
||||||
|
process.env.DATABASE_ADMIN_URL ??
|
||||||
|
process.env.DATABASE_URL ??
|
||||||
|
"postgres://postgres:postgres@localhost:5432/route_commerce",
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
if (doClean) {
|
||||||
|
const del = await client.query(
|
||||||
|
`DELETE FROM stops WHERE brand_id = $1 AND date >= '2026-07-01' AND date <= '2026-09-30'`,
|
||||||
|
[TUXEDO_BRAND_ID],
|
||||||
|
);
|
||||||
|
console.log(`\nCleared ${del.rowCount} prior 2026-dated stops for Tuxedo brand.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locations (from Stop Directory) — full replace for the brand from this xlsx
|
||||||
|
if (parsedLocations.length > 0) {
|
||||||
|
const locDel = await client.query(
|
||||||
|
`DELETE FROM locations WHERE brand_id = $1`,
|
||||||
|
[TUXEDO_BRAND_ID],
|
||||||
|
);
|
||||||
|
console.log(`Cleared ${locDel.rowCount} previous locations for Tuxedo brand.`);
|
||||||
|
|
||||||
|
const LOC_BATCH = 100; // locations are fewer and smaller
|
||||||
|
let locTotal = 0;
|
||||||
|
const locBatches = Math.ceil(parsedLocations.length / LOC_BATCH);
|
||||||
|
for (let i = 0; i < parsedLocations.length; i += LOC_BATCH) {
|
||||||
|
const batchLocs = parsedLocations.slice(i, i + LOC_BATCH);
|
||||||
|
const payload = batchLocs.map(toLocationRpcRow);
|
||||||
|
const bnum = Math.floor(i / LOC_BATCH) + 1;
|
||||||
|
process.stdout.write(` Locations batch ${bnum}/${locBatches} (${payload.length}) via admin_create_locations_batch... `);
|
||||||
|
try {
|
||||||
|
await client.query(
|
||||||
|
"SELECT admin_create_locations_batch($1::uuid, $2::jsonb)",
|
||||||
|
[TUXEDO_BRAND_ID, JSON.stringify(payload)],
|
||||||
|
);
|
||||||
|
locTotal += payload.length;
|
||||||
|
console.log("OK");
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log("FAIL");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` Inserted ${locTotal} locations.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BATCH = 50;
|
||||||
|
let total = 0;
|
||||||
|
const batches = Math.ceil(stops.length / BATCH);
|
||||||
|
|
||||||
|
for (let i = 0; i < stops.length; i += BATCH) {
|
||||||
|
const batchStops = stops.slice(i, i + BATCH);
|
||||||
|
const payload = batchStops.map(toRpcRow);
|
||||||
|
const bnum = Math.floor(i / BATCH) + 1;
|
||||||
|
|
||||||
|
process.stdout.write(` Batch ${bnum}/${batches} (${payload.length}) via admin_create_stops_batch... `);
|
||||||
|
try {
|
||||||
|
await client.query(
|
||||||
|
"SELECT admin_create_stops_batch($1::uuid, $2::jsonb)",
|
||||||
|
[TUXEDO_BRAND_ID, JSON.stringify(payload)],
|
||||||
|
);
|
||||||
|
total += payload.length;
|
||||||
|
console.log("OK");
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log("FAIL");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RPC inserts with status='draft' (per prior script); flip to active for public visibility.
|
||||||
|
// Scope by the tour date range so we only touch rows from this import.
|
||||||
|
if (total > 0) {
|
||||||
|
const pub = await client.query(
|
||||||
|
`UPDATE stops SET status = 'active' WHERE brand_id = $1 AND date >= '2026-07-01' AND date <= '2026-09-30'`,
|
||||||
|
[TUXEDO_BRAND_ID],
|
||||||
|
);
|
||||||
|
console.log(`\nPublished (status=active): ${pub.rowCount} stops.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
console.log(`\nDone. Inserted ${parsedLocations.length} locations + ${total} stops for Tuxedo Corn 2026 tour (brand ${TUXEDO_BRAND_ID}).`);
|
||||||
|
console.log("Locations are the master directory; stops are the dated schedule instances.");
|
||||||
|
console.log("They should now appear on /tuxedo/stops and in admin locations UI.");
|
||||||
|
} catch (err) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
console.error("\nInsert failed (rolled back):", err);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Seed Tuxedo Corn 2026 tour: locations + stops.
|
||||||
|
*
|
||||||
|
* Uses sql`...` tagged template from @neondatabase/serverless (WebSocket mode)
|
||||||
|
* for INSERTs — this is the only Neon serverless API that actually executes writes.
|
||||||
|
*
|
||||||
|
* Run with:
|
||||||
|
* node scripts/seed-tuxedo-2026.js
|
||||||
|
*
|
||||||
|
* Requires DATABASE_URL in .env.local (or pass explicitly).
|
||||||
|
*/
|
||||||
|
require("dotenv").config({ path: ".env.local" });
|
||||||
|
const { neon } = require("@neondatabase/serverless");
|
||||||
|
const ExcelJS = require("exceljs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||||
|
const YEAR = 2026;
|
||||||
|
|
||||||
|
const url = process.env.DATABASE_URL;
|
||||||
|
if (!url) {
|
||||||
|
console.error("❌ DATABASE_URL not set in .env.local");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanUrl = url.replace(/[?&]channel_binding=require/gi, "").trim();
|
||||||
|
// Use direct compute endpoint with WebSocket for full DDL + DML support
|
||||||
|
const directUrl = cleanUrl.replace("-pooler.", ".");
|
||||||
|
const sql = neon(directUrl, { webSocket: true });
|
||||||
|
|
||||||
|
const MONTH_MAP = {
|
||||||
|
Jan: "01", Feb: "02", Mar: "03", Apr: "04", May: "05", Jun: "06",
|
||||||
|
Jul: "07", Aug: "08", Sep: "09", Oct: "10", Nov: "11", Dec: "12",
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseExcelDate(s) {
|
||||||
|
if (!s) return null;
|
||||||
|
const m = /^([A-Za-z]{3})\s+(\d{1,2})$/.exec(String(s).trim());
|
||||||
|
if (!m) return null;
|
||||||
|
const mm = MONTH_MAP[m[1]];
|
||||||
|
if (!mm) return null;
|
||||||
|
return `${YEAR}-${mm}-${parseInt(m[2], 10).toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimeRange(s) {
|
||||||
|
if (!s) return "";
|
||||||
|
return String(s).replace(/[–—]/g, "-").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseXlsx(xlsxPath) {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
await workbook.xlsx.readFile(xlsxPath);
|
||||||
|
|
||||||
|
// --- Locations from Stop Directory ---
|
||||||
|
const locSheet = workbook.getWorksheet("Stop Directory");
|
||||||
|
const locations = [];
|
||||||
|
const seenLoc = new Set();
|
||||||
|
|
||||||
|
if (locSheet) {
|
||||||
|
locSheet.eachRow((row, rowNum) => {
|
||||||
|
if (rowNum === 1) return;
|
||||||
|
// Column mapping via getCell():
|
||||||
|
// col1=Truck, col2=City, col3=State, col4=Host(venue name), col5=Address, col6=Phone, col7=Contact, col12=Notes
|
||||||
|
const rawCity = String(row.getCell(2).value || "").trim();
|
||||||
|
const rawState = String(row.getCell(3).value || "").trim();
|
||||||
|
const rawName = String(row.getCell(4).value || "").trim();
|
||||||
|
const rawAddress = String(row.getCell(5).value || "").trim();
|
||||||
|
if (!rawCity || !rawName) return;
|
||||||
|
const key = `${rawName}|${rawCity}|${rawState}`;
|
||||||
|
if (seenLoc.has(key)) return;
|
||||||
|
seenLoc.add(key);
|
||||||
|
locations.push({
|
||||||
|
name: rawName,
|
||||||
|
address: rawAddress || null,
|
||||||
|
city: rawCity,
|
||||||
|
state: rawState,
|
||||||
|
zip: null,
|
||||||
|
phone: String(row.getCell(6).value || "").trim() || null,
|
||||||
|
contact_name: String(row.getCell(7).value || "").trim() || null,
|
||||||
|
contact_email: null,
|
||||||
|
notes: String(row.getCell(12).value || "").trim() || null,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`Parsed ${locations.length} locations`);
|
||||||
|
|
||||||
|
// --- Stops from Full Schedule ---
|
||||||
|
const schedSheet = workbook.getWorksheet("Full Schedule");
|
||||||
|
const stops = [];
|
||||||
|
const venueAddressMap = new Map();
|
||||||
|
|
||||||
|
if (locSheet) {
|
||||||
|
locSheet.eachRow((row, rowNum) => {
|
||||||
|
if (rowNum === 1) return;
|
||||||
|
const name = String(row.getCell(4).value || "").trim();
|
||||||
|
const address = String(row.getCell(5).value || "").trim();
|
||||||
|
if (name && address) venueAddressMap.set(name, address);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schedSheet) {
|
||||||
|
schedSheet.eachRow((row) => {
|
||||||
|
const rowValues = row.values || [];
|
||||||
|
// Column mapping (1-indexed, col1=null):
|
||||||
|
// col2=Wk, col3=Type, col4=Date, col5=Day, col6=City/Location, col7=Host/Venue, col8=Time, col9=Truck, col10=Status, col11=Notes
|
||||||
|
const firstCell = String(rowValues[1] || "").trim();
|
||||||
|
// Skip title rows, week headers ("Wk 1"), OFF, Cross-Dock
|
||||||
|
if (!firstCell || /^Wk\s*\d+/i.test(firstCell) || firstCell === "OFF" || firstCell === "Cross-Dock") return;
|
||||||
|
|
||||||
|
const dateStr = parseExcelDate(rowValues[3]);
|
||||||
|
if (!dateStr) return;
|
||||||
|
|
||||||
|
const cityStateRaw = String(rowValues[5] || "").trim();
|
||||||
|
const cityParts = cityStateRaw.split(",").map((s) => s.trim());
|
||||||
|
const city = cityParts[0] || "";
|
||||||
|
const state = cityParts[1] || "";
|
||||||
|
const location = String(rowValues[6] || "").trim();
|
||||||
|
if (!city || !state || !location) return;
|
||||||
|
|
||||||
|
const rawTime = rowValues[7];
|
||||||
|
const time = rawTime ? parseTimeRange(rawTime) : "";
|
||||||
|
const truck = String(rowValues[8] || "").trim();
|
||||||
|
const statusText = String(rowValues[9] || "").trim();
|
||||||
|
const notesText = String(rowValues[10] || "").trim();
|
||||||
|
|
||||||
|
const venueAddress = venueAddressMap.get(location) || null;
|
||||||
|
const notes = [truck ? `Truck: ${truck}` : "", statusText, notesText].filter(Boolean).join(" | ");
|
||||||
|
|
||||||
|
stops.push({
|
||||||
|
name: `${location} - ${city}, ${state}`,
|
||||||
|
location,
|
||||||
|
address: venueAddress,
|
||||||
|
city,
|
||||||
|
state,
|
||||||
|
zip: null,
|
||||||
|
date: dateStr,
|
||||||
|
time,
|
||||||
|
cutoff_date: null,
|
||||||
|
status: "active",
|
||||||
|
is_public: true,
|
||||||
|
notes: notes || null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`Parsed ${stops.length} stops`);
|
||||||
|
return { locations, stops };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedLocations(locs) {
|
||||||
|
if (locs.length === 0) return;
|
||||||
|
await sql`DELETE FROM locations WHERE brand_id = ${TUXEDO_BRAND_ID}::uuid`;
|
||||||
|
for (const loc of locs) {
|
||||||
|
await sql`INSERT INTO locations (brand_id, name, address, city, state, zip, phone, contact_name, contact_email, notes, active)
|
||||||
|
VALUES (${TUXEDO_BRAND_ID}::uuid, ${loc.name}, ${loc.address}, ${loc.city}, ${loc.state}, ${loc.zip}, ${loc.phone}, ${loc.contact_name}, ${loc.contact_email}, ${loc.notes}, ${loc.active})`;
|
||||||
|
}
|
||||||
|
console.log(`Seeded ${locs.length} locations`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedStops(stops) {
|
||||||
|
if (stops.length === 0) return;
|
||||||
|
const minDate = `${YEAR}-07-01`;
|
||||||
|
const maxDate = `${YEAR}-09-30`;
|
||||||
|
await sql`DELETE FROM stops WHERE brand_id = ${TUXEDO_BRAND_ID}::uuid AND date >= ${minDate} AND date <= ${maxDate}`;
|
||||||
|
for (const stop of stops) {
|
||||||
|
await sql`INSERT INTO stops (brand_id, name, location, address, city, state, zip, date, "time", cutoff_date, status, is_public, notes)
|
||||||
|
VALUES (${TUXEDO_BRAND_ID}::uuid, ${stop.name}, ${stop.location}, ${stop.address}, ${stop.city}, ${stop.state}, ${stop.zip}, ${stop.date}, ${stop.time}, ${stop.cutoff_date}, ${stop.status}, ${stop.is_public}, ${stop.notes})`;
|
||||||
|
}
|
||||||
|
console.log(`Seeded ${stops.length} stops`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const xlsxPath = path.join(__dirname, "..", "Tuxedo_Corn_2026_Tour_Schedule-3.xlsx");
|
||||||
|
console.log("Parsing:", xlsxPath);
|
||||||
|
const { locations, stops } = await parseXlsx(xlsxPath);
|
||||||
|
if (locations.length === 0 && stops.length === 0) {
|
||||||
|
console.error("❌ No data parsed from xlsx");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(`Seeding ${locations.length} locations + ${stops.length} stops for Tuxedo Corn...`);
|
||||||
|
await seedLocations(locations);
|
||||||
|
await seedStops(stops);
|
||||||
|
const locCount = await sql.query(`SELECT COUNT(*) FROM locations WHERE brand_id = '${TUXEDO_BRAND_ID}'`);
|
||||||
|
const stopCount = await sql.query(`SELECT COUNT(*) FROM stops WHERE brand_id = '${TUXEDO_BRAND_ID}'`);
|
||||||
|
console.log(`\n✅ Done — ${locCount[0].count} locations, ${stopCount[0].count} stops in DB`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error("❌ Seed failed:", e.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -38,10 +38,7 @@ MONTH_MAP = {
|
|||||||
"Sep": "09", "Oct": "10", "Nov": "11", "Dec": "12",
|
"Sep": "09", "Oct": "10", "Nov": "11", "Dec": "12",
|
||||||
}
|
}
|
||||||
|
|
||||||
DEFAULT_XLSX = (
|
DEFAULT_XLSX = "Tuxedo_Corn_2026_Tour_Schedule-3.xlsx" # run from repo root; override with --xlsx /path/to/file.xlsx
|
||||||
"/home/coder/dev/x1/kyle/route_commerce-main/"
|
|
||||||
"Tuxedo_Corn_2026_Tour_Schedule-3.xlsx"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_excel_date(s):
|
def parse_excel_date(s):
|
||||||
|
|||||||
+91
-26
@@ -1,46 +1,111 @@
|
|||||||
import LoadingSkeleton, { SkeletonCard, SkeletonTable } from "@/components/shared/LoadingSkeleton";
|
|
||||||
|
|
||||||
export default function AdminLoading() {
|
export default function AdminLoading() {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen px-4 sm:px-6 md:px-8 py-8" style={{ backgroundColor: "var(--admin-bg)" }}>
|
<main className="min-h-screen px-4 sm:px-6 md:px-8 py-6" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||||
<div className="max-w-7xl mx-auto space-y-6">
|
|
||||||
{/* Header skeleton */}
|
{/* Header skeleton */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="animate-pulse w-9 h-9 rounded-lg" style={{ backgroundColor: "var(--admin-border)" }} />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<LoadingSkeleton variant="text" width="w-48" height="h-8" />
|
<div className="animate-pulse h-5 w-36 rounded" style={{ backgroundColor: "var(--admin-border)" }} />
|
||||||
<LoadingSkeleton variant="text" width="w-64" height="h-4" />
|
<div className="animate-pulse h-3 w-24 rounded" style={{ backgroundColor: "var(--admin-border)" }} />
|
||||||
</div>
|
</div>
|
||||||
<LoadingSkeleton variant="button" />
|
</div>
|
||||||
|
<div className="animate-pulse h-8 w-28 rounded-lg" style={{ backgroundColor: "var(--admin-border)" }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats cards skeleton */}
|
{/* Stats cards skeleton */}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-5">
|
||||||
{[1, 2, 3, 4].map((i) => (
|
{[1, 2, 3, 4].map((i) => (
|
||||||
<div
|
<div key={i} className="rounded-xl bg-white border overflow-hidden">
|
||||||
key={i}
|
<div className="flex items-center gap-3 p-4">
|
||||||
className="rounded-xl border bg-white p-5"
|
<div className="animate-pulse w-9 h-9 rounded-xl flex-shrink-0" style={{ backgroundColor: "var(--admin-border)" }} />
|
||||||
style={{ borderColor: "var(--admin-border)" }}
|
<div className="space-y-1.5 flex-1">
|
||||||
>
|
<div className="animate-pulse h-2.5 w-16 rounded" style={{ backgroundColor: "var(--admin-border)" }} />
|
||||||
<div className="flex items-center gap-3">
|
<div className="animate-pulse h-6 w-12 rounded" style={{ backgroundColor: "var(--admin-border)" }} />
|
||||||
<div className="animate-pulse rounded-xl h-10 w-10" style={{ backgroundColor: "var(--admin-bg)" }} />
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<div className="animate-pulse h-3 w-16 rounded" style={{ backgroundColor: "var(--admin-bg)" }} />
|
|
||||||
<div className="animate-pulse h-5 w-12 rounded" style={{ backgroundColor: "var(--admin-bg)" }} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content cards skeleton */}
|
{/* Quick Actions + Usage skeleton */}
|
||||||
<div className="grid gap-4 lg:grid-cols-3">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3 mb-5">
|
||||||
<SkeletonCard />
|
<div className="rounded-xl bg-white border overflow-hidden">
|
||||||
<SkeletonCard />
|
<div className="flex items-center justify-between px-4 py-3 border-b" style={{ borderColor: "var(--admin-border-light)" }}>
|
||||||
<SkeletonCard />
|
<div className="animate-pulse h-3 w-20 rounded" style={{ backgroundColor: "var(--admin-border)" }} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 p-3">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div key={i} className="animate-pulse rounded-lg h-10" style={{ backgroundColor: "var(--admin-border)" }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-2 rounded-xl bg-white border overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-4 py-3 border-b" style={{ borderColor: "var(--admin-border-light)" }}>
|
||||||
|
<div className="animate-pulse h-5 w-16 rounded-full" style={{ backgroundColor: "var(--admin-border)" }} />
|
||||||
|
<div className="animate-pulse h-3 w-24 rounded" style={{ backgroundColor: "var(--admin-border)" }} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="animate-pulse h-2.5 w-10 rounded" style={{ backgroundColor: "var(--admin-border)" }} />
|
||||||
|
<div className="animate-pulse h-3 w-12 rounded" style={{ backgroundColor: "var(--admin-border)" }} />
|
||||||
|
</div>
|
||||||
|
<div className="animate-pulse h-1.5 rounded-full" style={{ backgroundColor: "var(--admin-border)" }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table skeleton */}
|
{/* Recent orders skeleton */}
|
||||||
<SkeletonTable rows={8} cols={4} />
|
<div className="rounded-xl bg-white border overflow-hidden mb-5">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b" style={{ borderColor: "var(--admin-border-light)" }}>
|
||||||
|
<div className="animate-pulse h-3 w-20 rounded" style={{ backgroundColor: "var(--admin-border)" }} />
|
||||||
|
<div className="animate-pulse h-3 w-14 rounded" style={{ backgroundColor: "var(--admin-border)" }} />
|
||||||
|
</div>
|
||||||
|
<div className="divide-y" style={{ borderColor: "var(--admin-border-light)" }}>
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="animate-pulse w-8 h-8 rounded-lg flex-shrink-0" style={{ backgroundColor: "var(--admin-border)" }} />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="animate-pulse h-3 w-28 rounded" style={{ backgroundColor: "var(--admin-border)" }} />
|
||||||
|
<div className="animate-pulse h-2.5 w-16 rounded" style={{ backgroundColor: "var(--admin-border)" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="animate-pulse h-3 w-14 rounded" style={{ backgroundColor: "var(--admin-border)" }} />
|
||||||
|
<div className="animate-pulse h-5 w-16 rounded-full" style={{ backgroundColor: "var(--admin-border)" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter tabs skeleton */}
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div key={i} className="animate-pulse h-9 w-20 rounded-lg" style={{ backgroundColor: "var(--admin-border)" }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section cards skeleton */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||||
|
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||||
|
<div key={i} className="rounded-xl bg-white border overflow-hidden">
|
||||||
|
<div className="flex items-start justify-between p-4">
|
||||||
|
<div className="animate-pulse w-9 h-9 rounded-xl" style={{ backgroundColor: "var(--admin-border)" }} />
|
||||||
|
<div className="animate-pulse w-12 h-4 rounded-full" style={{ backgroundColor: "var(--admin-border)" }} />
|
||||||
|
</div>
|
||||||
|
<div className="px-4 pb-4 space-y-1.5">
|
||||||
|
<div className="animate-pulse h-3 w-24 rounded" style={{ backgroundColor: "var(--admin-border)" }} />
|
||||||
|
<div className="animate-pulse h-2.5 w-full rounded" style={{ backgroundColor: "var(--admin-border)" }} />
|
||||||
|
<div className="animate-pulse h-2.5 w-3/4 rounded" style={{ backgroundColor: "var(--admin-border)" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
|
|||||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||||
import { getBillingOverview } from "@/actions/billing/billing-overview";
|
import { getBillingOverview } from "@/actions/billing/billing-overview";
|
||||||
|
import { getDashboardStats } from "@/actions/dashboard";
|
||||||
import DashboardClient from "@/components/admin/DashboardClient";
|
import DashboardClient from "@/components/admin/DashboardClient";
|
||||||
import { pool } from "@/lib/db";
|
import { pool } from "@/lib/db";
|
||||||
|
|
||||||
@@ -122,6 +123,10 @@ export default async function AdminPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch dashboard stats server-side to eliminate client-side waterfall.
|
||||||
|
// Stats are now available immediately — no "—" placeholder flash.
|
||||||
|
const stats = await getDashboardStats();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardClient
|
<DashboardClient
|
||||||
brandId={adminUser?.brand_id ?? null}
|
brandId={adminUser?.brand_id ?? null}
|
||||||
@@ -131,6 +136,7 @@ export default async function AdminPage() {
|
|||||||
enabledAddons={enabledAddons}
|
enabledAddons={enabledAddons}
|
||||||
usage={usage}
|
usage={usage}
|
||||||
limits={limits}
|
limits={limits}
|
||||||
|
stats={stats}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, type ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import UpgradePlanModal from "@/components/admin/UpgradePlanModal";
|
import dynamic from "next/dynamic";
|
||||||
import { PageHeader, AdminButton, AdminFilterTabs, AdminBadge } from "@/components/admin/design-system";
|
import { PageHeader, AdminButton, AdminFilterTabs, AdminBadge } from "@/components/admin/design-system";
|
||||||
import { getDashboardStats, type DashboardStats } from "@/actions/dashboard";
|
import type { DashboardStats } from "@/actions/dashboard";
|
||||||
|
|
||||||
|
// Lazy-load the upgrade modal — only needed when starter tier + brandId present
|
||||||
|
const UpgradePlanModal = dynamic(
|
||||||
|
() => import("@/components/admin/UpgradePlanModal"),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
|
||||||
type Section = {
|
type Section = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -17,112 +23,21 @@ type Section = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sections: Section[] = [
|
const sections: Section[] = [
|
||||||
{
|
{ title: "Orders", href: "/admin/orders", description: "View orders, pickup status, fulfillment, and customer details.", group: "operations", prominent: true },
|
||||||
title: "Orders",
|
{ title: "Products", href: "/admin/products", description: "Manage products, pricing, shipping type, and availability.", group: "operations", prominent: true },
|
||||||
href: "/admin/orders",
|
{ title: "Tours & Stops", href: "/admin/stops", description: "Manage routes, pickup locations, dates, and cutoff times.", group: "fulfillment" },
|
||||||
description: "View orders, pickup status, fulfillment, and customer details.",
|
{ title: "Driver Pickup", href: "/admin/pickup", description: "Mobile pickup lookup, QR scanning, and completion tools.", group: "fulfillment" },
|
||||||
group: "operations",
|
{ title: "Shipping", href: "/admin/shipping", description: "FedEx integration, label creation, and shipment tracking.", group: "fulfillment" },
|
||||||
prominent: true,
|
{ title: "Reports", href: "/admin/reports", description: "Sales, route, product, pickup, and customer reports.", group: "management" },
|
||||||
},
|
{ title: "Tax Dashboard", href: "/admin/taxes", description: "Sales tax collected, breakdown by state, and exportable reports.", group: "management" },
|
||||||
{
|
{ title: "Settings", href: "/admin/settings", description: "Users, billing, brand, integrations, payments, and shipping.", group: "management" },
|
||||||
title: "Products",
|
{ title: "Harvest Reach", href: "/admin/communications", description: "Email campaigns, stop blast, templates, and audience segments.", group: "tools", addonKey: "harvest_reach", upgradeText: "Enable to access email & SMS marketing" },
|
||||||
href: "/admin/products",
|
{ title: "Wholesale Portal", href: "/admin/wholesale", description: "Standalone B2B portal with custom pricing, credit limits, and net-30.", group: "tools", addonKey: "wholesale_portal", upgradeText: "Enable to unlock B2B buyer portal" },
|
||||||
description: "Manage products, pricing, shipping type, and availability.",
|
{ title: "Import Center", href: "/admin/import", description: "AI-powered import for products, orders, contacts, and stops.", group: "tools", addonKey: "ai_tools", upgradeText: "Enable AI import with smart column mapping" },
|
||||||
group: "operations",
|
{ title: "AI Intelligence", href: "/admin/settings/ai", description: "Campaign writer, pricing advisor, and report explainer.", group: "tools", addonKey: "ai_tools", upgradeText: "Enable AI tools for marketing and pricing" },
|
||||||
prominent: true,
|
{ title: "Time Tracking", href: "/admin/time-tracking", description: "Worker clock-in/out, hours tracking, and overtime management.", group: "tools", addonKey: "time_tracking", upgradeText: "Enable for field worker time tracking" },
|
||||||
},
|
{ title: "Water Log", href: "/admin/water-log", description: "Irrigation tracking and water usage reporting.", group: "tools", addonKey: "water_log", upgradeText: "Enable for agricultural water tracking" },
|
||||||
{
|
{ title: "Route Trace", href: "/admin/route-trace", description: "Lot tracking, QR stickers, hauling board, and supply chain traceability.", group: "tools", addonKey: "route_trace", upgradeText: "Enable for field-to-delivery traceability" },
|
||||||
title: "Tours & Stops",
|
|
||||||
href: "/admin/stops",
|
|
||||||
description: "Manage routes, pickup locations, dates, and cutoff times.",
|
|
||||||
group: "fulfillment",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Driver Pickup",
|
|
||||||
href: "/admin/pickup",
|
|
||||||
description: "Mobile pickup lookup, QR scanning, and completion tools.",
|
|
||||||
group: "fulfillment",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Shipping",
|
|
||||||
href: "/admin/shipping",
|
|
||||||
description: "FedEx integration, label creation, and shipment tracking.",
|
|
||||||
group: "fulfillment",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Reports",
|
|
||||||
href: "/admin/reports",
|
|
||||||
description: "Sales, route, product, pickup, and customer reports.",
|
|
||||||
group: "management",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Tax Dashboard",
|
|
||||||
href: "/admin/taxes",
|
|
||||||
description: "Sales tax collected, breakdown by state, and exportable reports.",
|
|
||||||
group: "management",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Settings",
|
|
||||||
href: "/admin/settings",
|
|
||||||
description: "Users, billing, brand, integrations, payments, and shipping.",
|
|
||||||
group: "management",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Harvest Reach",
|
|
||||||
href: "/admin/communications",
|
|
||||||
description: "Email campaigns, stop blast, templates, and audience segments.",
|
|
||||||
group: "tools",
|
|
||||||
addonKey: "harvest_reach",
|
|
||||||
upgradeText: "Enable to access email & SMS marketing",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Wholesale Portal",
|
|
||||||
href: "/admin/wholesale",
|
|
||||||
description: "Standalone B2B portal with custom pricing, credit limits, and net-30.",
|
|
||||||
group: "tools",
|
|
||||||
addonKey: "wholesale_portal",
|
|
||||||
upgradeText: "Enable to unlock B2B buyer portal",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Import Center",
|
|
||||||
href: "/admin/import",
|
|
||||||
description: "AI-powered import for products, orders, contacts, and stops.",
|
|
||||||
group: "tools",
|
|
||||||
addonKey: "ai_tools",
|
|
||||||
upgradeText: "Enable AI import with smart column mapping",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "AI Intelligence",
|
|
||||||
href: "/admin/settings/ai",
|
|
||||||
description: "Campaign writer, pricing advisor, and report explainer.",
|
|
||||||
group: "tools",
|
|
||||||
addonKey: "ai_tools",
|
|
||||||
upgradeText: "Enable AI tools for marketing and pricing",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Time Tracking",
|
|
||||||
href: "/admin/time-tracking",
|
|
||||||
description: "Worker clock-in/out, hours tracking, and overtime management.",
|
|
||||||
group: "tools",
|
|
||||||
addonKey: "time_tracking",
|
|
||||||
upgradeText: "Enable for field worker time tracking",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Water Log",
|
|
||||||
href: "/admin/water-log",
|
|
||||||
description: "Irrigation tracking and water usage reporting.",
|
|
||||||
group: "tools",
|
|
||||||
addonKey: "water_log",
|
|
||||||
upgradeText: "Enable for agricultural water tracking",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Route Trace",
|
|
||||||
href: "/admin/route-trace",
|
|
||||||
description: "Lot tracking, QR stickers, hauling board, and supply chain traceability.",
|
|
||||||
group: "tools",
|
|
||||||
addonKey: "route_trace",
|
|
||||||
upgradeText: "Enable for field-to-delivery traceability",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
type Tab = "operations" | "fulfillment" | "management" | "tools";
|
type Tab = "operations" | "fulfillment" | "management" | "tools";
|
||||||
@@ -132,7 +47,7 @@ const TABS: { id: Tab; label: string; icon: ReactNode }[] = [
|
|||||||
id: "operations",
|
id: "operations",
|
||||||
label: "Operations",
|
label: "Operations",
|
||||||
icon: (
|
icon: (
|
||||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
|
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
@@ -141,7 +56,7 @@ const TABS: { id: Tab; label: string; icon: ReactNode }[] = [
|
|||||||
id: "fulfillment",
|
id: "fulfillment",
|
||||||
label: "Fulfillment",
|
label: "Fulfillment",
|
||||||
icon: (
|
icon: (
|
||||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M16.5 9.4 7.55 4.24"/>
|
<path d="M16.5 9.4 7.55 4.24"/>
|
||||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
|
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
|
||||||
@@ -153,7 +68,7 @@ const TABS: { id: Tab; label: string; icon: ReactNode }[] = [
|
|||||||
id: "management",
|
id: "management",
|
||||||
label: "Management",
|
label: "Management",
|
||||||
icon: (
|
icon: (
|
||||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<circle cx="12" cy="12" r="3"/>
|
<circle cx="12" cy="12" r="3"/>
|
||||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -163,7 +78,7 @@ const TABS: { id: Tab; label: string; icon: ReactNode }[] = [
|
|||||||
id: "tools",
|
id: "tools",
|
||||||
label: "Tools",
|
label: "Tools",
|
||||||
icon: (
|
icon: (
|
||||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
|
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
@@ -178,6 +93,7 @@ type Props = {
|
|||||||
enabledAddons: Record<string, boolean>;
|
enabledAddons: Record<string, boolean>;
|
||||||
usage: { users: number; stops_this_month: number; products: number };
|
usage: { users: number; stops_this_month: number; products: number };
|
||||||
limits: { max_users: number; max_stops_monthly: number; max_products: number };
|
limits: { max_users: number; max_stops_monthly: number; max_products: number };
|
||||||
|
stats: DashboardStats;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DashboardClient({
|
export default function DashboardClient({
|
||||||
@@ -188,26 +104,13 @@ export default function DashboardClient({
|
|||||||
enabledAddons,
|
enabledAddons,
|
||||||
usage,
|
usage,
|
||||||
limits,
|
limits,
|
||||||
|
stats,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [activeTab, setActiveTab] = useState<Tab>("operations");
|
const [activeTab, setActiveTab] = useState<Tab>("operations");
|
||||||
const [isUpgradeOpen, setIsUpgradeOpen] = useState(false);
|
const [isUpgradeOpen, setIsUpgradeOpen] = useState(false);
|
||||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
|
||||||
const [isLoadingStats, setIsLoadingStats] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
// Stats are pre-fetched server-side — no loading state needed.
|
||||||
const loadStats = async () => {
|
// All values are guaranteed to be present before this component renders.
|
||||||
setIsLoadingStats(true);
|
|
||||||
try {
|
|
||||||
const data = await getDashboardStats();
|
|
||||||
setStats(data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load stats:", err);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingStats(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
loadStats();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const usagePct = {
|
const usagePct = {
|
||||||
users: limits.max_users > 0 ? (usage.users / limits.max_users) * 100 : 0,
|
users: limits.max_users > 0 ? (usage.users / limits.max_users) * 100 : 0,
|
||||||
@@ -217,15 +120,10 @@ export default function DashboardClient({
|
|||||||
|
|
||||||
const tabSections = sections.filter((s) => s.group === activeTab);
|
const tabSections = sections.filter((s) => s.group === activeTab);
|
||||||
|
|
||||||
// Format currency
|
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number) => {
|
||||||
return new Intl.NumberFormat("en-US", {
|
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
|
||||||
style: "currency",
|
|
||||||
currency: "USD",
|
|
||||||
}).format(amount);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get status badge color
|
|
||||||
const getStatusBadge = (status: string) => {
|
const getStatusBadge = (status: string) => {
|
||||||
const styles: Record<string, { bg: string; text: string }> = {
|
const styles: Record<string, { bg: string; text: string }> = {
|
||||||
pending: { bg: "var(--admin-warning-light)", text: "var(--admin-warning)" },
|
pending: { bg: "var(--admin-warning-light)", text: "var(--admin-warning)" },
|
||||||
@@ -235,7 +133,6 @@ export default function DashboardClient({
|
|||||||
return styles[status] || { bg: "var(--admin-bg)", text: "var(--admin-text-secondary)" };
|
return styles[status] || { bg: "var(--admin-bg)", text: "var(--admin-text-secondary)" };
|
||||||
};
|
};
|
||||||
|
|
||||||
// Quick action buttons
|
|
||||||
const quickActions = [
|
const quickActions = [
|
||||||
{ label: "New Order", href: "/admin/orders?new=true", icon: "plus" },
|
{ label: "New Order", href: "/admin/orders?new=true", icon: "plus" },
|
||||||
{ label: "Add Stop", href: "/admin/stops/new", icon: "map" },
|
{ label: "Add Stop", href: "/admin/stops/new", icon: "map" },
|
||||||
@@ -246,10 +143,10 @@ export default function DashboardClient({
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen" style={{ backgroundColor: "var(--admin-bg)" }}>
|
<div className="min-h-screen" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
<div className="px-4 sm:px-6 md:px-8 py-5 sm:py-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={
|
icon={
|
||||||
<svg className="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
||||||
<rect x="14" y="3" width="7" height="7" rx="1"/>
|
<rect x="14" y="3" width="7" height="7" rx="1"/>
|
||||||
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||||
@@ -257,17 +154,14 @@ export default function DashboardClient({
|
|||||||
</svg>
|
</svg>
|
||||||
}
|
}
|
||||||
title="Admin Dashboard"
|
title="Admin Dashboard"
|
||||||
subtitle={`${brandName} Control Center`}
|
subtitle={brandName}
|
||||||
actions={
|
actions={
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-3">
|
||||||
<a href="/admin/settings/billing" className="text-sm font-medium hover:underline" style={{ color: "var(--admin-text-muted)" }}>
|
<a href="/admin/settings/billing" className="hidden sm:block text-xs font-medium hover:underline" style={{ color: "var(--admin-text-muted)" }}>
|
||||||
Billing →
|
Billing →
|
||||||
</a>
|
</a>
|
||||||
{planTier === "starter" && brandId && (
|
{planTier === "starter" && brandId && (
|
||||||
<AdminButton
|
<AdminButton onClick={() => setIsUpgradeOpen(true)} size="sm">
|
||||||
onClick={() => setIsUpgradeOpen(true)}
|
|
||||||
size="md"
|
|
||||||
>
|
|
||||||
Upgrade Plan
|
Upgrade Plan
|
||||||
</AdminButton>
|
</AdminButton>
|
||||||
)}
|
)}
|
||||||
@@ -277,338 +171,226 @@ export default function DashboardClient({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Main Content */}
|
||||||
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6 -mt-4 space-y-6">
|
<div className="px-4 sm:px-6 md:px-8 pb-8 space-y-5">
|
||||||
{/* Stats Cards Row */}
|
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
{/* ── Stats Cards Row ────────────────────────────────────── */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 animate-fade-up" style={{ animationDelay: "0ms" }}>
|
||||||
{/* Today's Orders */}
|
{/* Today's Orders */}
|
||||||
<div
|
<div className="admin-stat-card">
|
||||||
className="rounded-xl border p-5 transition-all hover:shadow-md hover:-translate-y-0.5"
|
<div className="admin-stat-card-inner">
|
||||||
style={{
|
<div className="admin-stat-icon" style={{ backgroundColor: "var(--admin-accent-light)" }}>
|
||||||
backgroundColor: "var(--admin-card-bg)",
|
<svg className="w-4 h-4" style={{ color: "var(--admin-accent)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
borderColor: "var(--admin-border)"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div
|
|
||||||
className="flex h-12 w-12 items-center justify-center rounded-xl"
|
|
||||||
style={{ backgroundColor: "var(--admin-accent-light)" }}
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" style={{ color: "var(--admin-accent)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM12.94 18.55l.276-.276a.75.75 0 011.06 0l.27.27a.75.75 0 010 1.06l-.27.27a.75.75 0 01-1.06 0l-.276-.276a.75.75 0 010-1.06z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM12.94 18.55l.276-.276a.75.75 0 011.06 0l.27.27a.75.75 0 010 1.06l-.27.27a.75.75 0 01-1.06 0l-.276-.276a.75.75 0 010-1.06z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="admin-stat-body">
|
||||||
<p className="text-xs font-medium uppercase tracking-wide" style={{ color: "var(--admin-text-muted)" }}>
|
<span className="admin-stat-label">Today's Orders</span>
|
||||||
Today's Orders
|
<span className="admin-stat-value">{stats.todayOrders}</span>
|
||||||
</p>
|
|
||||||
<p className="text-2xl font-bold mt-0.5" style={{ color: "var(--admin-text-primary)" }}>
|
|
||||||
{isLoadingStats ? "—" : stats?.todayOrders ?? 0}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Today's Revenue */}
|
{/* Today's Revenue */}
|
||||||
<div
|
<div className="admin-stat-card">
|
||||||
className="rounded-xl border p-5 transition-all hover:shadow-md hover:-translate-y-0.5"
|
<div className="admin-stat-card-inner">
|
||||||
style={{
|
<div className="admin-stat-icon" style={{ backgroundColor: "#fef3c7" }}>
|
||||||
backgroundColor: "var(--admin-card-bg)",
|
<svg className="w-4 h-4 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
borderColor: "var(--admin-border)"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-amber-50">
|
|
||||||
<svg className="w-6 h-6 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="admin-stat-body">
|
||||||
<p className="text-xs font-medium uppercase tracking-wide" style={{ color: "var(--admin-text-muted)" }}>
|
<span className="admin-stat-label">Today's Revenue</span>
|
||||||
Today's Revenue
|
<span className="admin-stat-value ha-num">{formatCurrency(stats.todayRevenue)}</span>
|
||||||
</p>
|
|
||||||
<p className="text-2xl font-bold mt-0.5" style={{ color: "var(--admin-text-primary)" }}>
|
|
||||||
{isLoadingStats ? "—" : formatCurrency(stats?.todayRevenue ?? 0)}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pending Stops */}
|
{/* Pending Stops */}
|
||||||
<div
|
<div className="admin-stat-card">
|
||||||
className="rounded-xl border p-5 transition-all hover:shadow-md hover:-translate-y-0.5"
|
<div className="admin-stat-card-inner">
|
||||||
style={{
|
<div className="admin-stat-icon" style={{ backgroundColor: "#dbeafe" }}>
|
||||||
backgroundColor: "var(--admin-card-bg)",
|
<svg className="w-4 h-4 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
borderColor: "var(--admin-border)"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-50">
|
|
||||||
<svg className="w-6 h-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="admin-stat-body">
|
||||||
<p className="text-xs font-medium uppercase tracking-wide" style={{ color: "var(--admin-text-muted)" }}>
|
<span className="admin-stat-label">Pending Stops</span>
|
||||||
Pending Stops
|
<span className="admin-stat-value">{stats.pendingStops}</span>
|
||||||
</p>
|
|
||||||
<p className="text-2xl font-bold mt-0.5" style={{ color: "var(--admin-text-primary)" }}>
|
|
||||||
{isLoadingStats ? "—" : stats?.pendingStops ?? 0}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active Products — uses plan-aware usage.products so this card
|
{/* Active Products */}
|
||||||
always matches the "Products 0/25" usage bar below and the
|
<div className="admin-stat-card">
|
||||||
billing page's invoice/usage row. Previously this came from a
|
<div className="admin-stat-card-inner">
|
||||||
separate query (`active=eq.true` only) and could disagree with
|
<div className="admin-stat-icon" style={{ backgroundColor: "#f3e8ff" }}>
|
||||||
the plan limit usage, producing e.g. "1 vs 0/25" in the same
|
<svg className="w-4 h-4 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
dashboard. */}
|
|
||||||
<div
|
|
||||||
className="rounded-xl border p-5 transition-all hover:shadow-md hover:-translate-y-0.5"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--admin-card-bg)",
|
|
||||||
borderColor: "var(--admin-border)"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-50">
|
|
||||||
<svg className="w-6 h-6 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="admin-stat-body">
|
||||||
<p className="text-xs font-medium uppercase tracking-wide" style={{ color: "var(--admin-text-muted)" }}>
|
<span className="admin-stat-label">Active Products</span>
|
||||||
Active Products
|
<span className="admin-stat-value">{usage.products}</span>
|
||||||
</p>
|
|
||||||
<p className="text-2xl font-bold mt-0.5" style={{ color: "var(--admin-text-primary)" }}>
|
|
||||||
{/* usage.products comes from the server-rendered prop (via
|
|
||||||
getBillingOverview), so it's available immediately and
|
|
||||||
is guaranteed to match the "Products X/25" usage bar
|
|
||||||
and the billing page. */}
|
|
||||||
{usage.products}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Actions + Recent Orders Row */}
|
{/* ── Quick Actions + Usage Row ─────────────────────────── */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3 animate-fade-up" style={{ animationDelay: "60ms" }}>
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<div
|
<div className="admin-card-section">
|
||||||
className="rounded-xl border p-5"
|
<div className="admin-section-header">
|
||||||
style={{
|
<span className="admin-section-title">Quick Actions</span>
|
||||||
backgroundColor: "var(--admin-card-bg)",
|
</div>
|
||||||
borderColor: "var(--admin-border)"
|
<div className="admin-quick-actions">
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h3 className="text-sm font-semibold mb-4" style={{ color: "var(--admin-text-primary)" }}>
|
|
||||||
Quick Actions
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{quickActions.map((action) => (
|
{quickActions.map((action) => (
|
||||||
<Link
|
<Link
|
||||||
key={action.href}
|
key={action.href}
|
||||||
href={action.href}
|
href={action.href}
|
||||||
className="flex items-center gap-2 p-3 rounded-xl border transition-all hover:-translate-y-0.5 hover:shadow-sm"
|
className="admin-quick-action"
|
||||||
style={{
|
|
||||||
borderColor: "var(--admin-border-light)",
|
|
||||||
backgroundColor: "var(--admin-bg-subtle)"
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg" style={{ backgroundColor: "var(--admin-accent-light)" }}>
|
<div className="admin-quick-action-icon" style={{ backgroundColor: "var(--admin-accent-light)" }}>
|
||||||
{action.icon === "plus" && (
|
{action.icon === "plus" && (
|
||||||
<svg className="w-4 h-4" style={{ color: "var(--admin-accent)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="w-3.5 h-3.5" style={{ color: "var(--admin-accent)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
{action.icon === "map" && (
|
{action.icon === "map" && (
|
||||||
<svg className="w-4 h-4" style={{ color: "var(--admin-accent)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="w-3.5 h-3.5" style={{ color: "var(--admin-accent)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
{action.icon === "package" && (
|
{action.icon === "package" && (
|
||||||
<svg className="w-4 h-4" style={{ color: "var(--admin-accent)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="w-3.5 h-3.5" style={{ color: "var(--admin-accent)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
{action.icon === "mail" && (
|
{action.icon === "mail" && (
|
||||||
<svg className="w-4 h-4" style={{ color: "var(--admin-accent)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="w-3.5 h-3.5" style={{ color: "var(--admin-accent)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-3-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-3-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium" style={{ color: "var(--admin-text-secondary)" }}>
|
<span className="admin-quick-action-label">{action.label}</span>
|
||||||
{action.label}
|
<svg className="w-3.5 h-3.5 ml-auto opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
</span>
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||||
|
</svg>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Orders */}
|
{/* Usage Stats */}
|
||||||
<div
|
<div className="lg:col-span-2 admin-card-section">
|
||||||
className="lg:col-span-2 rounded-xl border overflow-hidden"
|
<div className="admin-section-header">
|
||||||
style={{
|
<div className="flex items-center gap-2">
|
||||||
backgroundColor: "var(--admin-card-bg)",
|
<AdminBadge variant={planTier === "enterprise" ? "warning" : planTier === "farm" ? "success" : "default"}>
|
||||||
borderColor: "var(--admin-border)"
|
{planTier.charAt(0).toUpperCase() + planTier.slice(1)}
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b" style={{ borderColor: "var(--admin-border-light)" }}>
|
|
||||||
<h3 className="text-sm font-semibold" style={{ color: "var(--admin-text-primary)" }}>
|
|
||||||
Recent Orders
|
|
||||||
</h3>
|
|
||||||
<Link
|
|
||||||
href="/admin/orders"
|
|
||||||
className="text-xs font-medium hover:underline"
|
|
||||||
style={{ color: "var(--admin-accent)" }}
|
|
||||||
>
|
|
||||||
View all →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="divide-y" style={{ borderColor: "var(--admin-border-light)" }}>
|
|
||||||
{stats?.recentOrders && stats.recentOrders.length > 0 ? (
|
|
||||||
stats.recentOrders.map((order) => {
|
|
||||||
const badge = getStatusBadge(order.status);
|
|
||||||
return (
|
|
||||||
<div key={order.id} className="flex items-center justify-between px-5 py-4 hover:bg-stone-50 transition-colors">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg" style={{ backgroundColor: "var(--admin-bg)" }}>
|
|
||||||
<svg className="w-5 h-5 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM12.94 18.55l.276-.276a.75.75 0 011.06 0l.27.27a.75.75 0 010 1.06l-.27.27a.75.75 0 01-1.06 0l-.276-.276a.75.75 0 010-1.06z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium" style={{ color: "var(--admin-text-primary)" }}>
|
|
||||||
{order.customer_name}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs" style={{ color: "var(--admin-text-muted)" }}>
|
|
||||||
{order.created_at}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="text-sm font-semibold" style={{ color: "var(--admin-text-primary)" }}>
|
|
||||||
{formatCurrency(order.total)}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="px-2.5 py-1 rounded-full text-xs font-medium capitalize"
|
|
||||||
style={{ backgroundColor: badge.bg, color: badge.text }}
|
|
||||||
>
|
|
||||||
{order.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<div className="px-5 py-12 text-center">
|
|
||||||
<svg className="w-12 h-12 mx-auto mb-3" style={{ color: "var(--admin-text-muted)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
|
||||||
</svg>
|
|
||||||
<p className="text-sm" style={{ color: "var(--admin-text-muted)" }}>
|
|
||||||
No recent orders
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href="/admin/orders?new=true"
|
|
||||||
className="inline-block mt-3 text-xs font-medium hover:underline"
|
|
||||||
style={{ color: "var(--admin-accent)" }}
|
|
||||||
>
|
|
||||||
Create your first order →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Usage stats bar */}
|
|
||||||
<div
|
|
||||||
className="rounded-xl border p-5"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--admin-card-bg)",
|
|
||||||
borderColor: "var(--admin-border)"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4 mb-4">
|
|
||||||
<AdminBadge variant={
|
|
||||||
planTier === "enterprise" ? "warning" :
|
|
||||||
planTier === "farm" ? "success" :
|
|
||||||
"default"
|
|
||||||
}>
|
|
||||||
{planTier.charAt(0).toUpperCase() + planTier.slice(1)} Plan
|
|
||||||
</AdminBadge>
|
</AdminBadge>
|
||||||
<span className="text-xs" style={{ color: "var(--admin-text-muted)" }}>
|
<span className="text-xs" style={{ color: "var(--admin-text-muted)" }}>{brandId ? brandName : "All Brands"}</span>
|
||||||
{brandId ? brandName : "All Brands"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 sm:gap-8">
|
<a href="/admin/settings/billing" className="text-xs font-medium hover:underline sm:hidden" style={{ color: "var(--admin-accent)" }}>
|
||||||
|
Manage →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="admin-usage-grid">
|
||||||
{[
|
{[
|
||||||
{ label: "Users", value: `${usage.users}/${limits.max_users}`, pct: usagePct.users, icon: "users" },
|
{ label: "Users", value: `${usage.users}/${limits.max_users}`, pct: usagePct.users, icon: "users" },
|
||||||
{ label: "Stops", value: `${usage.stops_this_month}/${limits.max_stops_monthly}`, pct: usagePct.stops, icon: "map" },
|
{ label: "Stops", value: `${usage.stops_this_month}/${limits.max_stops_monthly}`, pct: usagePct.stops, icon: "map" },
|
||||||
{ label: "Products", value: `${usage.products}/${limits.max_products}`, pct: usagePct.products, icon: "package" },
|
{ label: "Products", value: `${usage.products}/${limits.max_products}`, pct: usagePct.products, icon: "package" },
|
||||||
].map(({ label, value, pct }) => (
|
].map(({ label, value, pct }) => (
|
||||||
<div key={label}>
|
<div key={label} className="admin-usage-item">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="admin-usage-header">
|
||||||
<div className="flex items-center gap-2">
|
<span className="admin-usage-label">{label}</span>
|
||||||
{label === "Users" && (
|
<span className="admin-usage-value ha-num">{value}</span>
|
||||||
<svg className="w-4 h-4" style={{ color: "var(--admin-text-muted)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
{label === "Stops" && (
|
|
||||||
<svg className="w-4 h-4" style={{ color: "var(--admin-text-muted)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
{label === "Products" && (
|
|
||||||
<svg className="w-4 h-4" style={{ color: "var(--admin-text-muted)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
<span className="text-sm font-medium" style={{ color: "var(--admin-text-secondary)" }}>{label}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-semibold" style={{ color: "var(--admin-text-primary)" }}>{value}</span>
|
<div className="admin-usage-bar">
|
||||||
</div>
|
|
||||||
<div className="h-2.5 rounded-full overflow-hidden" style={{ backgroundColor: "var(--admin-bg)" }}>
|
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full transition-all duration-500 ease-out"
|
className="admin-usage-fill"
|
||||||
style={{
|
style={{
|
||||||
width: `${Math.min(pct, 100)}%`,
|
width: `${Math.min(pct, 100)}%`,
|
||||||
backgroundColor: pct > 90 ? "var(--admin-danger)" : pct > 75 ? "#f59e0b" : "var(--admin-accent)"
|
backgroundColor: pct > 90 ? "var(--admin-danger)" : pct > 75 ? "#f59e0b" : "var(--admin-accent)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{pct > 85 && (
|
{pct > 85 && (
|
||||||
<p className="text-xs mt-1.5" style={{ color: "var(--admin-danger)" }}>
|
<span className="admin-usage-warning">Near limit</span>
|
||||||
Near limit - consider upgrading
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Tab navigation */}
|
{/* ── Recent Orders ─────────────────────────────────────── */}
|
||||||
|
<div className="admin-card-section animate-fade-up" style={{ animationDelay: "120ms" }}>
|
||||||
|
<div className="admin-section-header">
|
||||||
|
<span className="admin-section-title">Recent Orders</span>
|
||||||
|
<Link href="/admin/orders" className="text-xs font-medium hover:underline" style={{ color: "var(--admin-accent)" }}>
|
||||||
|
View all →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{stats.recentOrders && stats.recentOrders.length > 0 ? (
|
||||||
|
<div className="admin-orders-list">
|
||||||
|
{stats.recentOrders.slice(0, 6).map((order) => {
|
||||||
|
const badge = getStatusBadge(order.status);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={order.id}
|
||||||
|
href={`/admin/orders`}
|
||||||
|
className="admin-order-row"
|
||||||
|
>
|
||||||
|
<div className="admin-order-info">
|
||||||
|
<div className="admin-order-icon">
|
||||||
|
<svg className="w-4 h-4 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM12.94 18.55l.276-.276a.75.75 0 011.06 0l.27.27a.75.75 0 010 1.06l-.27.27a.75.75 0 01-1.06 0l-.276-.276a.75.75 0 010-1.06z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="admin-order-name">{order.customer_name}</span>
|
||||||
|
<span className="admin-order-time">{order.created_at}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-order-meta">
|
||||||
|
<span className="admin-order-amount ha-num">{formatCurrency(order.total)}</span>
|
||||||
|
<span className="admin-order-badge" style={{ backgroundColor: badge.bg, color: badge.text }}>
|
||||||
|
{order.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="admin-orders-empty">
|
||||||
|
<svg className="w-10 h-10 mb-2" style={{ color: "var(--admin-text-muted)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm" style={{ color: "var(--admin-text-muted)" }}>No recent orders</p>
|
||||||
|
<Link href="/admin/orders?new=true" className="text-xs font-medium hover:underline mt-1" style={{ color: "var(--admin-accent)" }}>
|
||||||
|
Create your first order →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Section Navigation Tabs ───────────────────────────── */}
|
||||||
|
<div className="animate-fade-up" style={{ animationDelay: "180ms" }}>
|
||||||
<AdminFilterTabs
|
<AdminFilterTabs
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
onTabChange={(value) => setActiveTab(value as Tab)}
|
onTabChange={(value) => setActiveTab(value as Tab)}
|
||||||
tabs={TABS.map((tab) => ({
|
tabs={TABS.map((tab) => ({ value: tab.id, label: tab.label, icon: tab.icon }))}
|
||||||
value: tab.id,
|
|
||||||
label: tab.label,
|
|
||||||
icon: tab.icon,
|
|
||||||
}))}
|
|
||||||
size="md"
|
size="md"
|
||||||
showCounts={false}
|
showCounts={false}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Section Cards Grid */}
|
{/* ── Section Cards Grid ─────────────────────────────────── */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-4 gap-3 animate-fade-up" style={{ animationDelay: "240ms" }}>
|
||||||
{tabSections.map((section) => {
|
{tabSections.map((section) => {
|
||||||
if (section.title === "Water Log" && !isWaterLogVisible) return null;
|
if (section.title === "Water Log" && !isWaterLogVisible) return null;
|
||||||
if (section.title === "Route Trace" && !enabledAddons["route_trace"]) return null;
|
if (section.title === "Route Trace" && !enabledAddons["route_trace"]) return null;
|
||||||
@@ -622,87 +404,64 @@ export default function DashboardClient({
|
|||||||
key={section.title}
|
key={section.title}
|
||||||
href={section.href}
|
href={section.href}
|
||||||
className={[
|
className={[
|
||||||
"group relative flex flex-col rounded-xl border p-5 transition-all hover:-translate-y-0.5 hover:shadow-md",
|
"admin-section-card",
|
||||||
isAddon && !isEnabled
|
isAddon && !isEnabled ? "admin-section-card--locked" : "",
|
||||||
? "border-stone-200 shadow-sm opacity-75 hover:opacity-100"
|
isProminent ? "admin-section-card--prominent" : "",
|
||||||
: isProminent
|
].filter(Boolean).join(" ")}
|
||||||
? "border-[var(--admin-accent)]/20 shadow-[0_2px_8px_rgba(0,0,0,0.04)]"
|
|
||||||
: "border-stone-200 shadow-sm",
|
|
||||||
].join(" ")}
|
|
||||||
style={{ backgroundColor: "var(--admin-card-bg)" }}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="admin-section-card-top">
|
||||||
<div className={`flex h-10 w-10 items-center justify-center rounded-xl transition-transform group-hover:scale-110 ${
|
<div className={[
|
||||||
isAddon && !isEnabled
|
"admin-section-card-icon",
|
||||||
? "bg-stone-100 text-stone-400"
|
isAddon && !isEnabled ? "admin-section-card-icon--locked" : "",
|
||||||
: isProminent
|
isProminent ? "admin-section-card-icon--prominent" : "",
|
||||||
? "bg-emerald-50 text-emerald-600"
|
].filter(Boolean).join(" ")}>
|
||||||
: "bg-stone-100 text-stone-600"
|
|
||||||
}`}>
|
|
||||||
{section.title === "Orders" && (
|
{section.title === "Orders" && (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM12.94 18.55l.276-.276a.75.75 0 011.06 0l.27.27a.75.75 0 010 1.06l-.27.27a.75.75 0 01-1.06 0l-.276-.276a.75.75 0 010-1.06z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM12.94 18.55l.276-.276a.75.75 0 011.06 0l.27.27a.75.75 0 010 1.06l-.27.27a.75.75 0 01-1.06 0l-.276-.276a.75.75 0 010-1.06z" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
{section.title === "Products" && (
|
{section.title === "Products" && (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
{section.title === "Tours & Stops" && (
|
{section.title === "Tours & Stops" && (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
{section.title === "Driver Pickup" && (
|
{section.title === "Driver Pickup" && (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 00-10.026 0 1.106 1.106 0 00-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 00-10.026 0 1.106 1.106 0 00-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
{!["Orders", "Products", "Tours & Stops", "Driver Pickup"].includes(section.title) && (
|
{!["Orders", "Products", "Tours & Stops", "Driver Pickup"].includes(section.title) && (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isAddon && !isEnabled && (
|
<div className="flex items-center gap-1.5">
|
||||||
<AdminBadge variant="warning">
|
{isAddon && !isEnabled && <AdminBadge variant="warning">Add-on</AdminBadge>}
|
||||||
Add-on
|
{isAddon && isEnabled && <AdminBadge variant="success">Active</AdminBadge>}
|
||||||
</AdminBadge>
|
{isProminent && <AdminBadge variant="success">Core</AdminBadge>}
|
||||||
)}
|
</div>
|
||||||
{isAddon && isEnabled && (
|
|
||||||
<AdminBadge variant="success">
|
|
||||||
Active
|
|
||||||
</AdminBadge>
|
|
||||||
)}
|
|
||||||
{isProminent && (
|
|
||||||
<AdminBadge variant="success">
|
|
||||||
Core
|
|
||||||
</AdminBadge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="text-sm font-semibold leading-tight" style={{ color: "var(--admin-text-primary)" }}>
|
<div className="admin-section-card-body">
|
||||||
{section.title}
|
<h3 className="admin-section-card-title">{section.title}</h3>
|
||||||
</h3>
|
<p className={["admin-section-card-desc", isAddon && !isEnabled ? "text-stone-400" : ""].filter(Boolean).join(" ")}>
|
||||||
|
|
||||||
<p className={`mt-2 text-sm leading-relaxed ${
|
|
||||||
isAddon && !isEnabled ? "text-stone-500" : "text-stone-600"
|
|
||||||
}`}>
|
|
||||||
{section.description}
|
{section.description}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isAddon && !isEnabled && section.upgradeText && (
|
{isAddon && !isEnabled && section.upgradeText && (
|
||||||
<p className="mt-3 text-xs font-medium" style={{ color: "var(--admin-warning)" }}>
|
<p className="admin-section-card-hint">{section.upgradeText}</p>
|
||||||
{section.upgradeText}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Arrow indicator */}
|
<div className="admin-section-card-arrow">
|
||||||
<div className="mt-4 flex items-center text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity" style={{ color: "var(--admin-accent)" }}>
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
Open section
|
|
||||||
<svg className="w-4 h-4 ml-1 transform group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -712,7 +471,7 @@ export default function DashboardClient({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Upgrade Modal */}
|
{/* Upgrade Modal — lazy-loaded via next/dynamic */}
|
||||||
{planTier === "starter" && brandId && (
|
{planTier === "starter" && brandId && (
|
||||||
<UpgradePlanModal
|
<UpgradePlanModal
|
||||||
isOpen={isUpgradeOpen}
|
isOpen={isUpgradeOpen}
|
||||||
|
|||||||
@@ -1863,3 +1863,392 @@
|
|||||||
0 1px 2px rgba(60, 56, 37, 0.06),
|
0 1px 2px rgba(60, 56, 37, 0.06),
|
||||||
0 0 0 1px rgba(60, 56, 37, 0.06);
|
0 0 0 1px rgba(60, 56, 37, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================ */
|
||||||
|
/* === Admin Dashboard — Compact Card System ================== */
|
||||||
|
/* ============================================================ */
|
||||||
|
|
||||||
|
/* Entrance animation — staggered fade-up */
|
||||||
|
@keyframes dashboard-fade-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animate-fade-up {
|
||||||
|
animation: dashboard-fade-up 0.35s cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stat Card ─────────────────────────────────────────────── */
|
||||||
|
.admin-stat-card {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid var(--admin-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: box-shadow 150ms, transform 150ms;
|
||||||
|
}
|
||||||
|
.admin-stat-card:hover {
|
||||||
|
box-shadow: var(--admin-shadow-md);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.admin-stat-card-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
}
|
||||||
|
.admin-stat-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.admin-stat-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.admin-stat-label {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--admin-text-muted);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.admin-stat-value {
|
||||||
|
font-family: var(--font-fraunces), ui-serif, Georgia, serif;
|
||||||
|
font-size: 1.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variation-settings: "opsz" 144;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
color: var(--admin-text-primary);
|
||||||
|
line-height: 1;
|
||||||
|
font-variant-numeric: lining-nums tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Card Section (Quick Actions, Usage, Orders) ────────────── */
|
||||||
|
.admin-card-section {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid var(--admin-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.admin-section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--admin-border-light);
|
||||||
|
}
|
||||||
|
.admin-section-title {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--admin-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Quick Actions ──────────────────────────────────────────── */
|
||||||
|
.admin-quick-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
.admin-quick-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 150ms;
|
||||||
|
color: var(--admin-text-secondary);
|
||||||
|
}
|
||||||
|
.admin-quick-action:hover {
|
||||||
|
background: var(--admin-bg-subtle);
|
||||||
|
color: var(--admin-text-primary);
|
||||||
|
}
|
||||||
|
.admin-quick-action-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.admin-quick-action-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Usage Grid ─────────────────────────────────────────────── */
|
||||||
|
.admin-usage-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.admin-usage-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.admin-usage-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
.admin-usage-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.admin-usage-label {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--admin-text-muted);
|
||||||
|
}
|
||||||
|
.admin-usage-value {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--admin-text-primary);
|
||||||
|
}
|
||||||
|
.admin-usage-bar {
|
||||||
|
height: 0.375rem;
|
||||||
|
background: var(--admin-bg);
|
||||||
|
border-radius: 9999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.admin-usage-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 9999px;
|
||||||
|
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.admin-usage-warning {
|
||||||
|
font-size: 0.5625rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--admin-danger);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Recent Orders ──────────────────────────────────────────── */
|
||||||
|
.admin-orders-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.admin-order-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--admin-border-light);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 150ms;
|
||||||
|
}
|
||||||
|
.admin-order-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.admin-order-row:hover {
|
||||||
|
background: var(--admin-bg-subtle);
|
||||||
|
}
|
||||||
|
.admin-order-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.admin-order-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--admin-bg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.admin-order-name {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--admin-text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 12rem;
|
||||||
|
}
|
||||||
|
.admin-order-time {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--admin-text-muted);
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
.admin-order-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.admin-order-amount {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--admin-text-primary);
|
||||||
|
}
|
||||||
|
.admin-order-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.1875rem 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
.admin-orders-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2.5rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section Navigation Cards ───────────────────────────────── */
|
||||||
|
.admin-section-card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid var(--admin-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: box-shadow 180ms, transform 180ms, border-color 180ms;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.admin-section-card:hover {
|
||||||
|
box-shadow: var(--admin-shadow-md);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.admin-section-card--locked {
|
||||||
|
opacity: 0.65;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
.admin-section-card--locked:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.admin-section-card--prominent {
|
||||||
|
border-color: rgba(22, 163, 74, 0.25);
|
||||||
|
background: linear-gradient(180deg, #fafffe 0%, #ffffff 60%);
|
||||||
|
}
|
||||||
|
.admin-section-card-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.125rem;
|
||||||
|
}
|
||||||
|
.admin-section-card-icon {
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
background: var(--admin-bg);
|
||||||
|
color: var(--admin-text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: transform 180ms;
|
||||||
|
}
|
||||||
|
.admin-section-card:hover .admin-section-card-icon {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
.admin-section-card-icon--prominent {
|
||||||
|
background: var(--admin-accent-light);
|
||||||
|
color: var(--admin-accent-text);
|
||||||
|
}
|
||||||
|
.admin-section-card-icon--locked {
|
||||||
|
background: var(--admin-bg);
|
||||||
|
color: var(--admin-text-muted);
|
||||||
|
}
|
||||||
|
.admin-section-card-body {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.admin-section-card-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--admin-text-primary);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.admin-section-card-desc {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--admin-text-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.admin-section-card-hint {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--admin-warning);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.admin-section-card-arrow {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.75rem;
|
||||||
|
right: 0.75rem;
|
||||||
|
color: var(--admin-accent);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-4px);
|
||||||
|
transition: opacity 180ms, transform 180ms;
|
||||||
|
}
|
||||||
|
.admin-section-card:hover .admin-section-card-arrow {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile optimizations ───────────────────────────────────── */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.admin-stat-card-inner {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
.admin-stat-value {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
.admin-quick-actions {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.admin-order-name {
|
||||||
|
max-width: 8rem;
|
||||||
|
}
|
||||||
|
.admin-section-card {
|
||||||
|
padding: 0.875rem;
|
||||||
|
}
|
||||||
|
.admin-section-card-title {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
.admin-section-card-desc {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user