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:
Nora
2026-06-26 06:24:02 -06:00
parent adce211480
commit 9633680e23
13 changed files with 145 additions and 240 deletions
@@ -171,4 +171,4 @@ export type ImportHistoryItem = {
url: string;
};
export { previewContactImport, type ImportPreviewResult };
export { type ImportPreviewResult };
+1 -3
View File
@@ -1025,6 +1025,4 @@ function cryptoRandomHex(bytes: number): string {
return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("");
}
// Re-export for the field action to do an admin-PIN check.
export { verifyPin };
export { logAlert };
// Re-exports removed — callers import from @/lib/water-log-pin and @/lib/water-log-audit directly.
+101 -112
View File
@@ -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(
+11 -11
View File
@@ -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).
+10 -10
View File
@@ -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");
+7 -7
View File
@@ -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;
-59
View File
@@ -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),
+5 -5
View File
@@ -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();