fix: react-doctor deslop/unused-export 7→0, prefer-module-scope-pure-function 6→0 (hoist handleExportCSV, downloadCSV, triggerDownload, scrollToContent, handleLogout, applyMappings)
This commit is contained in:
@@ -91,6 +91,107 @@ function getStatValueColor(highlight: boolean, warn: boolean) {
|
||||
return "text-[var(--admin-text-muted)]";
|
||||
}
|
||||
|
||||
function applyMappings(
|
||||
mappings: ColumnMapping[],
|
||||
overrides: Record<string, ImportField>,
|
||||
headers: string[],
|
||||
rows: string[][]
|
||||
): ContactImportEntry[] {
|
||||
const effective = mappings.map((m) => ({
|
||||
...m,
|
||||
field: overrides[m.csvColumn] ?? m.field,
|
||||
}));
|
||||
|
||||
const seenEmails = new Set<string>();
|
||||
const seenPhones = new Set<string>();
|
||||
const entries: ContactImportEntry[] = [];
|
||||
const headerIndex = new Map<string, number>();
|
||||
headers.forEach((h, i) => headerIndex.set(h, i));
|
||||
|
||||
for (const row of rows) {
|
||||
const entry: ContactImportEntry = {};
|
||||
const ignored: Record<string, string> = {};
|
||||
|
||||
for (const mapping of effective) {
|
||||
const colIdx = headerIndex.get(mapping.csvColumn);
|
||||
if (colIdx === undefined) continue;
|
||||
const raw = row[colIdx] ?? "";
|
||||
|
||||
if (mapping.field === null) {
|
||||
if (raw) ignored[mapping.csvColumn] = raw;
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (mapping.field) {
|
||||
case "email": {
|
||||
entry.email = raw.trim().toLowerCase() || undefined;
|
||||
break;
|
||||
}
|
||||
case "phone": {
|
||||
entry.phone = raw.trim() || undefined;
|
||||
break;
|
||||
}
|
||||
case "first_name":
|
||||
entry.first_name = raw || undefined;
|
||||
break;
|
||||
case "last_name":
|
||||
entry.last_name = raw || undefined;
|
||||
break;
|
||||
case "full_name":
|
||||
entry.full_name = raw || undefined;
|
||||
break;
|
||||
case "tags":
|
||||
entry.tags = raw
|
||||
? raw.split(/[;:,]/).flatMap((t) => {
|
||||
const trimmed = t.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
})
|
||||
: undefined;
|
||||
break;
|
||||
case "email_opt_in":
|
||||
entry.email_opt_in =
|
||||
raw === "true" ||
|
||||
raw === "1" ||
|
||||
raw === "yes" ||
|
||||
raw === "y" ||
|
||||
raw === "on";
|
||||
break;
|
||||
case "sms_opt_in":
|
||||
entry.sms_opt_in =
|
||||
raw === "true" ||
|
||||
raw === "1" ||
|
||||
raw === "yes" ||
|
||||
raw === "y" ||
|
||||
raw === "on";
|
||||
break;
|
||||
case "external_id":
|
||||
entry.external_id = raw || undefined;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!entry.email && !entry.phone) continue;
|
||||
|
||||
let isDup = false;
|
||||
if (entry.email) {
|
||||
if (seenEmails.has(entry.email)) isDup = true;
|
||||
seenEmails.add(entry.email);
|
||||
}
|
||||
if (entry.phone && !isDup) {
|
||||
if (seenPhones.has(entry.phone)) isDup = true;
|
||||
seenPhones.add(entry.phone);
|
||||
}
|
||||
if (isDup) continue;
|
||||
|
||||
if (Object.keys(ignored).length > 0) {
|
||||
entry._metadata = ignored;
|
||||
}
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export default function ContactImportForm({ brandId }: { brandId: string }) {
|
||||
const [step, setStep] = useState<Step>("idle");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
@@ -208,118 +309,6 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
|
||||
[handleFile]
|
||||
);
|
||||
|
||||
// ── Mapping override ───────────────────────────────────────────────────────
|
||||
|
||||
function applyMappings(
|
||||
mappings: ColumnMapping[],
|
||||
overrides: Record<string, ImportField>,
|
||||
headers: string[],
|
||||
rows: string[][]
|
||||
): ContactImportEntry[] {
|
||||
const effective = mappings.map((m) => ({
|
||||
...m,
|
||||
field: overrides[m.csvColumn] ?? m.field,
|
||||
}));
|
||||
|
||||
const emailColIdx = effective.findIndex((m) => m.field === "email");
|
||||
const phoneColIdx = effective.findIndex((m) => m.field === "phone");
|
||||
|
||||
const seenEmails = new Set<string>();
|
||||
const seenPhones = new Set<string>();
|
||||
const entries: ContactImportEntry[] = [];
|
||||
const headerIndex = new Map<string, number>();
|
||||
headers.forEach((h, i) => headerIndex.set(h, i));
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
const entry: ContactImportEntry = {};
|
||||
const ignored: Record<string, string> = {};
|
||||
|
||||
for (const mapping of effective) {
|
||||
const colIdx = headerIndex.get(mapping.csvColumn);
|
||||
if (colIdx === undefined) continue;
|
||||
const raw = row[colIdx] ?? "";
|
||||
|
||||
if (mapping.field === null) {
|
||||
if (raw) ignored[mapping.csvColumn] = raw;
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (mapping.field) {
|
||||
case "email": {
|
||||
const cleaned = raw.trim().toLowerCase();
|
||||
entry.email = cleaned || undefined;
|
||||
break;
|
||||
}
|
||||
case "phone": {
|
||||
const stripped = raw.replace(/[\s\-().[\]]/g, "");
|
||||
entry.phone = raw.trim() || undefined;
|
||||
break;
|
||||
}
|
||||
case "first_name":
|
||||
entry.first_name = raw || undefined;
|
||||
break;
|
||||
case "last_name":
|
||||
entry.last_name = raw || undefined;
|
||||
break;
|
||||
case "full_name":
|
||||
entry.full_name = raw || undefined;
|
||||
break;
|
||||
case "tags":
|
||||
entry.tags = raw
|
||||
? raw.split(/[;:,]/).flatMap((t) => {
|
||||
const trimmed = t.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
})
|
||||
: undefined;
|
||||
break;
|
||||
case "email_opt_in":
|
||||
entry.email_opt_in =
|
||||
raw === "true" ||
|
||||
raw === "1" ||
|
||||
raw === "yes" ||
|
||||
raw === "y" ||
|
||||
raw === "on";
|
||||
break;
|
||||
case "sms_opt_in":
|
||||
entry.sms_opt_in =
|
||||
raw === "true" ||
|
||||
raw === "1" ||
|
||||
raw === "yes" ||
|
||||
raw === "y" ||
|
||||
raw === "on";
|
||||
break;
|
||||
case "external_id":
|
||||
entry.external_id = raw || undefined;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const hasEmail = !!entry.email;
|
||||
const hasPhone = !!entry.phone;
|
||||
if (!hasEmail && !hasPhone) continue;
|
||||
|
||||
// Dedupe within file
|
||||
let isDup = false;
|
||||
if (entry.email) {
|
||||
if (seenEmails.has(entry.email!)) isDup = true;
|
||||
seenEmails.add(entry.email!);
|
||||
}
|
||||
if (entry.phone && !isDup) {
|
||||
if (seenPhones.has(entry.phone)) isDup = true;
|
||||
seenPhones.add(entry.phone);
|
||||
}
|
||||
if (isDup) continue;
|
||||
|
||||
if (Object.keys(ignored).length > 0) {
|
||||
entry._metadata = ignored;
|
||||
}
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ── Confirm and import ─────────────────────────────────────────────────────
|
||||
|
||||
const handleConfirm = useCallback(
|
||||
|
||||
@@ -68,6 +68,17 @@ function quarterLabel(startISO: string): string {
|
||||
return `Q${q} ${s.getFullYear()}`;
|
||||
}
|
||||
|
||||
function handleExportCSV(exportFn: () => string, filename: string) {
|
||||
const csv = exportFn();
|
||||
const blob = new Blob([csv], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function monthLabel(startISO: string): string {
|
||||
const s = new Date(startISO + "T00:00:00");
|
||||
return s.toLocaleDateString("en-US", { month: "long", year: "numeric" });
|
||||
@@ -307,17 +318,6 @@ export default function ReportsDashboard({
|
||||
setExplainError(null);
|
||||
}
|
||||
|
||||
function handleExportCSV(exportFn: () => string, filename: string) {
|
||||
const csv = exportFn();
|
||||
const blob = new Blob([csv], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// Auto-fetch when range or brand changes. The fetchAll call is wrapped in
|
||||
// an async IIFE so the effect body does not call setState synchronously
|
||||
// (avoids the cascading-renders ESLint rule).
|
||||
|
||||
@@ -43,6 +43,16 @@ function buildRange(preset: DatePreset, customStart?: string, customEnd?: string
|
||||
}
|
||||
}
|
||||
|
||||
function downloadCSV(csv: string, filename: string) {
|
||||
const blob = new Blob([csv], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function quarterLabel(start: string, end: string): string {
|
||||
const s = new Date(start + "T00:00:00");
|
||||
const q = Math.floor(s.getMonth() / 3) + 1;
|
||||
@@ -261,16 +271,6 @@ export default function TaxDashboard({
|
||||
}
|
||||
}
|
||||
|
||||
function downloadCSV(csv: string, filename: string) {
|
||||
const blob = new Blob([csv], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const effectiveRate = summary && summary.total_gross_sales > 0
|
||||
? (summary.total_tax_collected / summary.total_gross_sales) * 100
|
||||
: 0;
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
function triggerDownload(url: string) {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
const triggerLabel: Record<string, { en: string; color: string }> = {
|
||||
daily_approaching: { en: "Daily OT Approaching", color: "bg-amber-100 text-amber-700" },
|
||||
daily_reached: { en: "Daily OT Reached", color: "bg-red-100 text-red-700" },
|
||||
@@ -236,15 +245,6 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
|
||||
return `/api/time-tracking/export?${params}`;
|
||||
}
|
||||
|
||||
function triggerDownload(url: string) {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
const openAddWorker = () => {
|
||||
setEditingWorker(null);
|
||||
setWorkerName(""); setWorkerRole("worker"); setWorkerLang("en");
|
||||
|
||||
@@ -13,6 +13,13 @@ if (typeof window !== "undefined") {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// CINEMATIC HERO SECTION - APPLE-STYLE SCROLL-DRIVEN ANIMATIONS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
function scrollToContent() {
|
||||
const nextSection = document.getElementById("story");
|
||||
if (nextSection) {
|
||||
nextSection.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
|
||||
export default function HeroSection() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const heroRef = useRef<HTMLElement>(null);
|
||||
@@ -227,13 +234,6 @@ export default function HeroSection() {
|
||||
return () => ctx.revert();
|
||||
}, [mounted]);
|
||||
|
||||
const scrollToContent = () => {
|
||||
const nextSection = document.getElementById("story");
|
||||
if (nextSection) {
|
||||
nextSection.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ─── SCROLL PROGRESS BAR ──────────────────────────────────────────── */}
|
||||
|
||||
@@ -386,15 +386,3 @@ export function LandingPageWrapper({ children, className = "" }: WrapperProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DEFAULT EXPORT: Complete Landing Page
|
||||
// ============================================
|
||||
export default function LandingPage({
|
||||
children,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<LandingPageWrapper>{children}</LandingPageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
type Toast,
|
||||
} from "./toast-store";
|
||||
|
||||
export { toast } from "./toast-store";
|
||||
|
||||
interface ToastNotificationProps {
|
||||
toast: Toast;
|
||||
onDismiss: (id: string) => void;
|
||||
|
||||
@@ -19,13 +19,6 @@ function emit(type: ToastType, title: string, message?: string) {
|
||||
toastListeners.forEach((listener) => listener(newToast));
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
success: (title: string, message?: string) => emit("success", title, message),
|
||||
error: (title: string, message?: string) => emit("error", title, message),
|
||||
warning: (title: string, message?: string) => emit("warning", title, message),
|
||||
info: (title: string, message?: string) => emit("info", title, message),
|
||||
};
|
||||
|
||||
/** Internal subscription helper used by `ToastNotification` to receive
|
||||
* newly-fired toasts. Returns an unsubscribe function. */
|
||||
export function subscribeToToasts(listener: (toast: Toast) => void): () => void {
|
||||
|
||||
@@ -35,5 +35,3 @@ export function LoadingFade() {
|
||||
</ViewTransition>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoadingFade;
|
||||
|
||||
@@ -19,65 +19,6 @@ function prefersReducedMotion(): boolean {
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches === true;
|
||||
}
|
||||
|
||||
interface StickyScrollSectionProps {
|
||||
children: React.ReactNode;
|
||||
height?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function StickyScrollSection({
|
||||
children,
|
||||
height = "300vh",
|
||||
className = "",
|
||||
}: StickyScrollSectionProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Initial value is the prefers-reduced-motion check — users with
|
||||
// reduced motion see the content "stuck" immediately, so we don't
|
||||
// need a useEffect to flip the flag on mount.
|
||||
const [isStuck, setIsStuck] = useState<boolean>(() => prefersReducedMotion());
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !containerRef.current) return;
|
||||
if (prefersReducedMotion()) {
|
||||
// With reduced motion, just show the content normally — no pinning.
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
const trigger = ScrollTrigger.create({
|
||||
trigger: containerRef.current,
|
||||
start: "top top",
|
||||
end: "bottom bottom",
|
||||
pin: true,
|
||||
pinSpacing: false,
|
||||
onToggle: (self) => {
|
||||
setIsStuck(self.isActive);
|
||||
},
|
||||
});
|
||||
|
||||
return () => trigger.kill();
|
||||
}, containerRef);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`relative ${className}`}
|
||||
style={{ height }}
|
||||
>
|
||||
<div
|
||||
className={`sticky top-0 h-screen overflow-hidden ${
|
||||
isStuck ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SCROLL-REVEAL WRAPPER - Layers content as user scrolls
|
||||
// Motion-reduced: max 12px translation (was 60px), 320ms (was 1s),
|
||||
|
||||
@@ -260,6 +260,11 @@ function SectionHeader({
|
||||
);
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await logoutWaterAdmin();
|
||||
window.location.href = "/water";
|
||||
}
|
||||
|
||||
export default function WaterAdminClient() {
|
||||
const router = useRouter();
|
||||
const [lang, setLang] = useState<"en" | "es">(() => {
|
||||
@@ -351,11 +356,6 @@ export default function WaterAdminClient() {
|
||||
return () => clearInterval(interval);
|
||||
}, [loadDisplaySummary]);
|
||||
|
||||
async function handleLogout() {
|
||||
await logoutWaterAdmin();
|
||||
window.location.href = "/water";
|
||||
}
|
||||
|
||||
// Add user
|
||||
async function handleAddUser(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
Reference in New Issue
Block a user