feat: Implement guest import functionality with a new API route and an admin dashboard dialog.
This commit is contained in:
127
src/app/api/admin/guests/import/route.ts
Normal file
127
src/app/api/admin/guests/import/route.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { verifyAdminSession } from "@/lib/supabase-auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { generateUniqueInviteCode } from "@/lib/invite-code";
|
||||
import { sanitizeName, sanitizeMessage } from "@/lib/sanitize";
|
||||
import { CACHE_TAGS, invalidateCacheByTag } from "@/lib/api-cache";
|
||||
import { getRequestId } from "@/lib/request-id";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
export const maxDuration = 60; // Allow 60 seconds for bulk import
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = getRequestId(request);
|
||||
|
||||
try {
|
||||
const session = await verifyAdminSession(request);
|
||||
if (!session || !session.authenticated) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify CSRF token
|
||||
const { verifyCSRF } = await import('@/lib/csrf');
|
||||
const csrfValid = await verifyCSRF(request);
|
||||
if (!csrfValid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'CSRF token invalid or missing' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { guests } = body;
|
||||
|
||||
if (!Array.isArray(guests) || guests.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No guests provided for import' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Process guests
|
||||
let successCount = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
// We process sequentially to ensure invite code uniqueness (to avoid race conditions on DB check)
|
||||
// and to avoid overwhelming the DB connection pool with hundreds of parallel requests
|
||||
for (const [index, guest] of guests.entries()) {
|
||||
try {
|
||||
if (!guest.name) {
|
||||
errors.push(`Row ${index + 1}: Name is required`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sanitize fields
|
||||
const sanitizedName = sanitizeName(guest.name);
|
||||
const sanitizedEmail = guest.email ? sanitizeMessage(guest.email) : null;
|
||||
const sanitizedPhone = guest.phone ? sanitizeMessage(guest.phone) : null;
|
||||
const sanitizedRelationship = guest.relationship ? sanitizeMessage(guest.relationship) : 'Guest';
|
||||
const notes = guest.notes ? sanitizeMessage(guest.notes) : null;
|
||||
|
||||
// Handle RSVP status from import
|
||||
let rsvpStatus = 'PENDING';
|
||||
let isAttending = null;
|
||||
|
||||
if (guest.status) {
|
||||
const status = guest.status.toLowerCase();
|
||||
if (status === 'confirmed' || status === 'accepted' || status === 'attending') {
|
||||
rsvpStatus = 'ACCEPTED';
|
||||
isAttending = true;
|
||||
} else if (status === 'declined' || status === 'regret') {
|
||||
rsvpStatus = 'DECLINED';
|
||||
isAttending = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate Code
|
||||
const inviteCode = await generateUniqueInviteCode();
|
||||
|
||||
await prisma.guest.create({
|
||||
data: {
|
||||
name: sanitizedName,
|
||||
email: sanitizedEmail,
|
||||
phone: sanitizedPhone,
|
||||
inviteCode: inviteCode,
|
||||
maxPax: guest.maxPax ? Number(guest.maxPax) : 1,
|
||||
relationship: sanitizedRelationship,
|
||||
notes: notes,
|
||||
rsvpStatus: rsvpStatus as any,
|
||||
isAttending: isAttending as any,
|
||||
plusOnesCount: guest.plusOne ? 1 : 0, // Simplified: if plusOne is true, allow 1 guest
|
||||
tableId: guest.tableId || null,
|
||||
}
|
||||
});
|
||||
|
||||
successCount++;
|
||||
} catch (err: any) {
|
||||
logger.error(`Import failed for row ${index + 1}`, err, { requestId });
|
||||
errors.push(`Row ${index + 1}: ${err.message || 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate caches
|
||||
await Promise.all([
|
||||
invalidateCacheByTag(CACHE_TAGS.GUESTS),
|
||||
invalidateCacheByTag(CACHE_TAGS.STATS),
|
||||
invalidateCacheByTag(CACHE_TAGS.ADMIN),
|
||||
]);
|
||||
|
||||
// return summary
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
count: successCount,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Import API error', error instanceof Error ? error : new Error(String(error)), { requestId });
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
import { AddGuestDialog } from "./dialogs/add-guest-dialog";
|
||||
import { EditGuestDialog } from "./dialogs/edit-guest-dialog";
|
||||
import { MagicLinkDialog } from "./dialogs/magic-link-dialog";
|
||||
import { ImportGuestsDialog } from "./dialogs/import-guests-dialog";
|
||||
import { AdminRSVPOnBehalf } from "./admin-rsvp-on-behalf";
|
||||
import { Guest } from "@/types/admin";
|
||||
import AdminTabErrorBoundary from "./admin-tab-error-boundary";
|
||||
@@ -82,6 +83,7 @@ export function AdminDashboard({
|
||||
const [isMagicLinkOpen, setIsMagicLinkOpen] = useState(false);
|
||||
const [magicLinkDestination, setMagicLinkDestination] = useState<"rsvp" | "tickets" | "dashboard">("rsvp");
|
||||
const [isRSVPBehalfOpen, setIsRSVPBehalfOpen] = useState(false);
|
||||
const [isImportGuestsOpen, setIsImportGuestsOpen] = useState(false);
|
||||
const [targetGuest, setTargetGuest] = useState<Guest | null>(null);
|
||||
|
||||
// Guest Handlers
|
||||
@@ -151,12 +153,8 @@ export function AdminDashboard({
|
||||
}, [toast]);
|
||||
|
||||
const handleImportGuests = useCallback(() => {
|
||||
toast({
|
||||
title: "Coming Soon",
|
||||
description: "CSV import functionality is being finalized.",
|
||||
variant: "info"
|
||||
});
|
||||
}, [toast]);
|
||||
setIsImportGuestsOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleRSVPOnBehalf = useCallback((guest: Guest) => {
|
||||
setTargetGuest(guest);
|
||||
@@ -455,6 +453,12 @@ export function AdminDashboard({
|
||||
onSuccess={() => guestListData.refreshGuestList()}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ImportGuestsDialog
|
||||
open={isImportGuestsOpen}
|
||||
onOpenChange={setIsImportGuestsOpen}
|
||||
onSuccess={() => guestListData.refreshGuestList()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
294
src/components/features/admin/dialogs/import-guests-dialog.tsx
Normal file
294
src/components/features/admin/dialogs/import-guests-dialog.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Loader2, Upload, AlertCircle, CheckCircle2, FileText, X } from "lucide-react";
|
||||
import { parseCSV, ParseCSVResult } from "@/lib/import-utils";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Guest } from "@/types/admin";
|
||||
|
||||
interface ImportGuestsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function ImportGuestsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: ImportGuestsDialogProps) {
|
||||
const { toast } = useToast();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [parsedResult, setParsedResult] = useState<ParseCSVResult<Partial<Guest>> | null>(null);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
|
||||
const handleFile = async (file: File) => {
|
||||
if (file.type !== "text/csv" && !file.name.endsWith(".csv")) {
|
||||
toast({
|
||||
variant: "error",
|
||||
title: "Invalid file type",
|
||||
description: "Please upload a CSV file.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setFile(file);
|
||||
try {
|
||||
const result = await parseCSV<Partial<Guest>>(file);
|
||||
setParsedResult(result);
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "error",
|
||||
title: "Parse Error",
|
||||
description: "Failed to parse CSV file.",
|
||||
});
|
||||
setFile(null);
|
||||
setParsedResult(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive(true);
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
handleFile(e.dataTransfer.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
e.preventDefault();
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
handleFile(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setFile(null);
|
||||
setParsedResult(null);
|
||||
};
|
||||
|
||||
const getCSRFToken = useCallback(async (): Promise<string | null> => {
|
||||
try {
|
||||
const response = await fetch("/api/csrf", {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return data.csrfToken || data.token;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to get CSRF token:", error);
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!parsedResult?.data.length) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const csrfToken = await getCSRFToken();
|
||||
const response = await fetch("/api/admin/guests/import", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken && { "x-csrf-token": csrfToken }),
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ guests: parsedResult.data }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "Failed to import guests");
|
||||
}
|
||||
|
||||
toast({
|
||||
variant: "success",
|
||||
title: "Import Successful",
|
||||
description: `Successfully imported ${data.count} guests.`,
|
||||
});
|
||||
onSuccess();
|
||||
onOpenChange(false);
|
||||
reset();
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "error",
|
||||
title: "Import Failed",
|
||||
description: error instanceof Error ? error.message : "Failed to import guests",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[800px] max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-heading">Import Guests</DialogTitle>
|
||||
<DialogDescription className="font-body">
|
||||
Upload a CSV file to bulk import guests. The file should have headers like Name, Email, Status, etc.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-hidden flex flex-col gap-4 py-4">
|
||||
{!file ? (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center p-10 border-2 border-dashed rounded-xl transition-colors ${dragActive
|
||||
? "border-wedding-sage bg-wedding-sage/5"
|
||||
: "border-gray-200 hover:border-wedding-sage/50"
|
||||
}`}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Upload className="h-10 w-10 text-gray-400 mb-4" />
|
||||
<p className="font-medium text-gray-700 mb-1">Drag and drop your CSV here</p>
|
||||
<p className="text-sm text-gray-400 mb-4">or click to browse</p>
|
||||
<div className="relative">
|
||||
<Button variant="outline" size="sm">
|
||||
Browse Files
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||
accept=".csv"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-full gap-4">
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-100 rounded-lg text-green-700">
|
||||
<FileText className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">{file.name}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{(file.size / 1024).toFixed(1)} KB • {parsedResult?.data.length || 0} guests found
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={reset}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{parsedResult?.errors && parsedResult.errors.length > 0 && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Errors found in file</AlertTitle>
|
||||
<AlertDescription>
|
||||
{parsedResult.errors.slice(0, 3).map((err, i) => (
|
||||
<div key={i}>{err}</div>
|
||||
))}
|
||||
{parsedResult.errors.length > 3 && (
|
||||
<div>...and {parsedResult.errors.length - 3} more errors</div>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex-1 border rounded-md overflow-hidden flex flex-col min-h-0">
|
||||
<div className="p-2 bg-gray-50 border-b text-xs font-medium text-gray-500">
|
||||
Preview ({parsedResult?.data.length} records)
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Row</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Max Pax</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{parsedResult?.data.slice(0, 50).map((row, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="font-mono text-xs">{i + 1}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{row.name || <span className="text-red-400 italic">Missing</span>}
|
||||
</TableCell>
|
||||
<TableCell>{row.email || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{row.status ? (
|
||||
<Badge variant="outline" className="text-xs">{row.status}</Badge>
|
||||
) : (
|
||||
<span className="text-gray-400 text-xs">Default (Pending)</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{row.maxPax || 1}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{parsedResult?.data && parsedResult.data.length > 50 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-xs text-gray-500">
|
||||
...and {parsedResult.data.length - 50} more
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={!file || !parsedResult?.data.length || (parsedResult?.errors?.length ?? 0) > 0 || isSubmitting}
|
||||
className="bg-wedding-evergreen hover:bg-wedding-moss"
|
||||
>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Import {parsedResult?.data.length ? `${parsedResult.data.length} Guests` : ''}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React from "react";
|
||||
import { RefreshCw, Settings, Radio, BarChart3, X } from "lucide-react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -24,6 +25,7 @@ export function OverviewTab({
|
||||
onDeleteMusicRequest,
|
||||
adminData: propAdminData,
|
||||
}: OverviewTabProps) {
|
||||
const { toast } = useToast();
|
||||
const internalAdminData = useAdminData();
|
||||
const adminData = propAdminData || internalAdminData;
|
||||
|
||||
@@ -148,15 +150,27 @@ export function OverviewTab({
|
||||
<CardTitle className="font-heading">Quick Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Button className="w-full justify-start" variant="ghost">
|
||||
<Button
|
||||
className="w-full justify-start"
|
||||
variant="ghost"
|
||||
onClick={() => toast({ title: "Coming Soon", description: "Broadcast messaging is under development.", variant: "info" })}
|
||||
>
|
||||
<Radio className="h-4 w-4 mr-2" />
|
||||
Send Broadcast Message
|
||||
</Button>
|
||||
<Button className="w-full justify-start" variant="ghost">
|
||||
<Button
|
||||
className="w-full justify-start"
|
||||
variant="ghost"
|
||||
onClick={() => toast({ title: "Coming Soon", description: "Reporting features are coming in the next update.", variant: "info" })}
|
||||
>
|
||||
<BarChart3 className="h-4 w-4 mr-2" />
|
||||
Generate Report
|
||||
</Button>
|
||||
<Button className="w-full justify-start" variant="ghost">
|
||||
<Button
|
||||
className="w-full justify-start"
|
||||
variant="ghost"
|
||||
onClick={() => onTabChange?.("settings")}
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Event Settings
|
||||
</Button>
|
||||
|
||||
119
src/lib/import-utils.ts
Normal file
119
src/lib/import-utils.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
|
||||
export interface ParseCSVResult<T> {
|
||||
data: T[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple CSV parser that handles quoted values and basic validation
|
||||
*/
|
||||
export async function parseCSV<T = any>(file: File): Promise<ParseCSVResult<T>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const text = event.target?.result as string;
|
||||
if (!text) {
|
||||
resolve({ data: [], errors: ["Empty file"] });
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = text.split(/\r?\n/).filter(line => line.trim().length > 0);
|
||||
if (lines.length < 2) {
|
||||
resolve({ data: [], errors: ["File must contain a header row and at least one data row"] });
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse headers
|
||||
const headers = parseCSVLine(lines[0]).map(h => h.trim());
|
||||
|
||||
const data: T[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
// Parse data rows
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const currentLine = lines[i];
|
||||
if (!currentLine.trim()) continue;
|
||||
|
||||
const values = parseCSVLine(currentLine);
|
||||
|
||||
if (values.length !== headers.length) {
|
||||
errors.push(`Row ${i + 1}: Expected ${headers.length} columns but found ${values.length}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const row: any = {};
|
||||
headers.forEach((header, index) => {
|
||||
// Normalize header key (camelCase preferred but keep it simple for now)
|
||||
const key = header.replace(/\s+/g, '').replace(/[^a-zA-Z0-9]/g, '');
|
||||
let value: any = values[index]?.trim();
|
||||
|
||||
// Basic type inference
|
||||
if (value === "true") value = true;
|
||||
else if (value === "false") value = false;
|
||||
else if (value === "" || value === "null") value = null;
|
||||
else if (!isNaN(Number(value)) && value !== "") value = Number(value);
|
||||
|
||||
// Map common CSV headers to Guest type keys if needed
|
||||
// This mapping matches the export templates
|
||||
let mappedKey = key;
|
||||
if (key.toLowerCase() === 'name') mappedKey = 'name';
|
||||
else if (key.toLowerCase() === 'email') mappedKey = 'email';
|
||||
else if (key.toLowerCase() === 'phone') mappedKey = 'phone';
|
||||
else if (key.toLowerCase() === 'dietary') mappedKey = 'dietaryRestrictions';
|
||||
else if (key.toLowerCase() === 'dietaryrestrictions') mappedKey = 'dietaryRestrictions';
|
||||
else if (key.toLowerCase() === 'plusone') mappedKey = 'plusOne'; // Although plusOne is boolean, might come as 'Yes'/'No'
|
||||
else if (key.toLowerCase() === 'maxpax') mappedKey = 'maxPax';
|
||||
else if (key.toLowerCase() === 'table') mappedKey = 'tableId';
|
||||
else if (key.toLowerCase() === 'notes') mappedKey = 'notes';
|
||||
|
||||
row[mappedKey] = value;
|
||||
});
|
||||
|
||||
data.push(row);
|
||||
}
|
||||
|
||||
resolve({ data, errors });
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => reject(new Error("Failed to read file"));
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to parse a single CSV line handling quotes
|
||||
*/
|
||||
function parseCSVLine(text: string): string[] {
|
||||
const result: string[] = [];
|
||||
let currentValue = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
|
||||
if (char === '"') {
|
||||
if (inQuotes && text[i + 1] === '"') {
|
||||
// Escaped quote
|
||||
currentValue += '"';
|
||||
i++;
|
||||
} else {
|
||||
// Toggle quotes
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
// End of value
|
||||
result.push(currentValue);
|
||||
currentValue = '';
|
||||
} else {
|
||||
currentValue += char;
|
||||
}
|
||||
}
|
||||
|
||||
result.push(currentValue);
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user