feat: Add UI components for edit functionality and email preferences
UI Components: - Add edit buttons and modals for guestbook messages (guest dashboard) - Add edit buttons and modals for tributes (guest dashboard + admin) - Add 'My Messages' and 'My Tributes' sections in guest dashboard - Add 'edited' indicators to guestbook and tribute displays - Add RSVP email preferences toggle in admin settings - Add edit button to admin tributes list with edit dialog - Add Who's Who filtering UI (relationship and opt-in filters) API Updates: - Update guestbook GET to include updatedAt, guestId, isEdited - Update tribute GET to include updatedAt, guestId, isEdited - Update admin tributes GET to include updatedAt and isEdited TypeScript Fixes: - Fix type annotations in activity-log, reports, and other routes - Fix Guest type compatibility issues - Fix inviteCode optional chaining - Regenerate Prisma client for new schema fields Features Completed: ✅ Edit guestbook messages (admin + guest) ✅ Edit tributes (admin + guest) ✅ Edit relationship (admin dashboard - already existed) ✅ RSVP email notifications (backend + UI preferences) ✅ Who's Who filtering (relationship + opt-in) ✅ Reports & exports (statistics + CSV exports) ✅ Activity feed enhancements ✅ 'Edited' indicators on messages/tributes
This commit is contained in:
@@ -20,7 +20,7 @@ export default function RsvpLayout({
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
// If already authenticated and codes match, we are good
|
||||
if (guest && guest.inviteCode.toUpperCase() === inviteCode?.toUpperCase()) {
|
||||
if (guest && guest.inviteCode && guest.inviteCode.toUpperCase() === inviteCode?.toUpperCase()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -255,7 +255,7 @@ function RSVPContent() {
|
||||
dietaryRestrictions: guest.dietaryRestrictions ?? null,
|
||||
confirmationCode: guest.confirmationCode ?? null,
|
||||
}}
|
||||
inviteCode={guest.inviteCode}
|
||||
inviteCode={guest.inviteCode || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
recentRSVPs.forEach((guest) => {
|
||||
recentRSVPs.forEach((guest: { id: string; name: string; surname: string | null; rsvpStatus: string | null; rsvpTimestamp: Date | null }) => {
|
||||
if (guest.rsvpTimestamp) {
|
||||
activities.push({
|
||||
id: `rsvp-${guest.id}-${guest.rsvpTimestamp.getTime()}`,
|
||||
@@ -70,7 +70,7 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
recentGuestbook.forEach((entry) => {
|
||||
recentGuestbook.forEach((entry: { id: string; guestName: string; message: string; createdAt: Date; updatedAt: Date }) => {
|
||||
const isEdited = entry.updatedAt.getTime() > entry.createdAt.getTime() + 1000; // 1 second buffer
|
||||
activities.push({
|
||||
id: `guestbook-${entry.id}`,
|
||||
@@ -100,7 +100,7 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
recentTributes.forEach((tribute) => {
|
||||
recentTributes.forEach((tribute: { id: string; guestName: string; dedication: string; createdAt: Date; updatedAt: Date }) => {
|
||||
const isEdited = tribute.updatedAt.getTime() > tribute.createdAt.getTime() + 1000;
|
||||
activities.push({
|
||||
id: `tribute-${tribute.id}`,
|
||||
@@ -129,7 +129,7 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
recentSongs.forEach((song) => {
|
||||
recentSongs.forEach((song: { id: string; title: string; artist: string | null; createdAt: Date }) => {
|
||||
activities.push({
|
||||
id: `song-${song.id}`,
|
||||
type: "music",
|
||||
@@ -160,7 +160,7 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
recentProfileUpdates.forEach((guest) => {
|
||||
recentProfileUpdates.forEach((guest: { id: string; name: string; surname: string | null; updatedAt: Date; createdAt: Date; profileCompleted: boolean | null }) => {
|
||||
const isNew = guest.createdAt.getTime() === guest.updatedAt.getTime();
|
||||
if (!isNew && guest.profileCompleted) {
|
||||
activities.push({
|
||||
|
||||
@@ -54,7 +54,10 @@ export async function PUT(
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid request data",
|
||||
details: validation.error.errors,
|
||||
details: validation.error.issues.map((issue) => ({
|
||||
field: issue.path.join("."),
|
||||
message: issue.message,
|
||||
})),
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
|
||||
@@ -58,7 +58,7 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
data = guests.map((guest) => ({
|
||||
data = guests.map((guest: { id: string; name: string; surname: string | null; email: string | null; phone: string | null; rsvpStatus: string | null; maxPax: number; plusOnesCount: number; attendingAlone: boolean | null; rsvpTimestamp: Date | null; dietaryRestrictions: string | null; relationship: string | null }) => ({
|
||||
"Full Name": `${guest.name}${guest.surname ? ` ${guest.surname}` : ""}`,
|
||||
"RSVP Status": guest.rsvpStatus || "PENDING",
|
||||
"Party Size": guest.attendingAlone
|
||||
@@ -97,7 +97,7 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
data = guestsWithDietary.map((guest) => ({
|
||||
data = guestsWithDietary.map((guest: { name: string; surname: string | null; dietaryRestrictions: string | null; maxPax: number; plusOnesCount: number; attendingAlone: boolean | null; email: string | null }) => ({
|
||||
"Guest Name": `${guest.name}${guest.surname ? ` ${guest.surname}` : ""}`,
|
||||
"Dietary Restriction": guest.dietaryRestrictions || "",
|
||||
"Party Size": guest.attendingAlone
|
||||
@@ -127,7 +127,7 @@ export async function GET(request: NextRequest) {
|
||||
],
|
||||
});
|
||||
|
||||
data = allGuests.map((guest) => ({
|
||||
data = allGuests.map((guest: { name: string; surname: string | null; relationship: string | null; rsvpStatus: string | null; maxPax: number; plusOnesCount: number; attendingAlone: boolean | null }) => ({
|
||||
"Relationship": guest.relationship || "Unknown",
|
||||
"Guest Name": `${guest.name}${guest.surname ? ` ${guest.surname}` : ""}`,
|
||||
"RSVP Status": guest.rsvpStatus || "PENDING",
|
||||
@@ -173,7 +173,7 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
data = guestsWithTables.map((guest) => ({
|
||||
data = guestsWithTables.map((guest: { id: string; name: string; surname: string | null; tableId: string | null; table: { name: string } | null; dietaryRestrictions: string | null; maxPax: number; plusOnesCount: number; attendingAlone: boolean | null }) => ({
|
||||
"Table": guest.table?.name || `Table ${guest.tableId}`,
|
||||
"Guest Name": `${guest.name}${guest.surname ? ` ${guest.surname}` : ""}`,
|
||||
"Dietary Notes": guest.dietaryRestrictions || "",
|
||||
|
||||
@@ -70,7 +70,7 @@ export async function GET(request: NextRequest) {
|
||||
]);
|
||||
|
||||
// Calculate total attending (confirmed guests + their plus-ones)
|
||||
const totalAttending = allGuests.reduce((total, guest) => {
|
||||
const totalAttending = allGuests.reduce((total: number, guest: { attendingAlone: boolean | null; plusOnesCount: number; maxPax: number | null }) => {
|
||||
if (guest.attendingAlone) {
|
||||
return total + 1;
|
||||
}
|
||||
@@ -91,7 +91,7 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
|
||||
const dietaryMap = new Map<string, number>();
|
||||
guestsWithDietary.forEach((guest) => {
|
||||
guestsWithDietary.forEach((guest: { dietaryRestrictions: string | null }) => {
|
||||
if (guest.dietaryRestrictions) {
|
||||
const dietary = guest.dietaryRestrictions.toLowerCase().trim();
|
||||
dietaryMap.set(dietary, (dietaryMap.get(dietary) || 0) + 1);
|
||||
@@ -117,11 +117,11 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
|
||||
const relationships = relationshipBreakdown
|
||||
.map((item) => ({
|
||||
.map((item: { relationship: string | null; _count: { relationship: number } }) => ({
|
||||
relationship: item.relationship || "Unknown",
|
||||
count: item._count.relationship,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
.sort((a: { count: number }, b: { count: number }) => b.count - a.count);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -92,7 +92,10 @@ export async function PUT(request: NextRequest) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid request data",
|
||||
details: validation.error.errors,
|
||||
details: validation.error.issues.map((issue) => ({
|
||||
field: issue.path.join("."),
|
||||
message: issue.message,
|
||||
})),
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
|
||||
@@ -32,6 +32,7 @@ export async function GET(request: NextRequest) {
|
||||
guestName: true,
|
||||
dedication: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -48,7 +49,7 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
|
||||
// Format tributes with time ago
|
||||
const formattedTributes = tributes.map((tribute: { id: string; guestId: string; guestName: string; dedication: string; createdAt: Date }) => {
|
||||
const formattedTributes = tributes.map((tribute: { id: string; guestId: string | null; guestName: string; dedication: string; createdAt: Date; updatedAt: Date }) => {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - tribute.createdAt.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
@@ -64,11 +65,16 @@ export async function GET(request: NextRequest) {
|
||||
timeAgo = `${diffDays} ${diffDays === 1 ? 'day' : 'days'} ago`;
|
||||
}
|
||||
|
||||
const isEdited = tribute.updatedAt.getTime() > tribute.createdAt.getTime() + 1000; // 1 second buffer
|
||||
|
||||
return {
|
||||
id: tribute.id,
|
||||
guestId: tribute.guestId,
|
||||
guestName: tribute.guestName,
|
||||
dedication: tribute.dedication,
|
||||
timestamp: tribute.createdAt.toISOString(),
|
||||
updatedAt: tribute.updatedAt.toISOString(),
|
||||
isEdited,
|
||||
timeAgo,
|
||||
date: tribute.createdAt.toISOString(),
|
||||
};
|
||||
|
||||
@@ -69,10 +69,7 @@ export async function PUT(
|
||||
|
||||
// Verify ownership - guests can only edit their own messages
|
||||
if (existing.guestId !== session.guestId) {
|
||||
audit.unauthorizedAccess("guest", "guestbook", "edit", session.guestId, {
|
||||
attemptedEntryId: id,
|
||||
actualOwnerId: existing.guestId,
|
||||
});
|
||||
audit.accessDenied("guest", "guestbook", session.guestId);
|
||||
return NextResponse.json(
|
||||
{ error: "You can only edit your own messages" },
|
||||
{ status: 403 }
|
||||
|
||||
@@ -36,17 +36,23 @@ async function fetchGuestbookEntriesFromDatabase(skip: number, take: number) {
|
||||
take,
|
||||
});
|
||||
|
||||
return entries.map((entry: { id: string; guestName: string; message: string; photo?: string | null; createdAt: Date; guest: { name: string; avatar?: string | null } }) => ({
|
||||
return entries.map((entry: { id: string; guestName: string; message: string; photo?: string | null; createdAt: Date; updatedAt: Date; guestId: string; guest: { name: string; avatar?: string | null } | null }) => {
|
||||
const isEdited = entry.updatedAt.getTime() > entry.createdAt.getTime() + 1000;
|
||||
return {
|
||||
id: entry.id,
|
||||
guestName: entry.guestName,
|
||||
message: entry.message,
|
||||
photo: entry.photo || undefined,
|
||||
timestamp: entry.createdAt.toISOString(),
|
||||
guest: {
|
||||
updatedAt: entry.updatedAt.toISOString(),
|
||||
guestId: entry.guestId,
|
||||
isEdited: entry.updatedAt.getTime() > entry.createdAt.getTime() + 1000, // 1 second buffer
|
||||
guest: entry.guest ? {
|
||||
name: entry.guest.name,
|
||||
avatar: entry.guest.avatar || undefined,
|
||||
},
|
||||
}));
|
||||
} : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
|
||||
@@ -757,7 +757,7 @@ export async function POST(request: NextRequest) {
|
||||
partySize: updatedGuest.maxPax || 1,
|
||||
dietaryRestrictions: sanitizedDietary,
|
||||
specialRequests: notes ? sanitizeMessage(notes) : null,
|
||||
rsvpTimestamp: updatedGuest.rsvpTimestamp || now,
|
||||
rsvpTimestamp: updatedGuest.rsvpTimestamp || new Date(),
|
||||
relationship: updatedGuest.relationship,
|
||||
email: updatedGuest.email,
|
||||
phone: updatedGuest.phone,
|
||||
@@ -766,7 +766,7 @@ export async function POST(request: NextRequest) {
|
||||
const subject = `New RSVP: ${updatedGuest.name} - ${attending ? 'Attending' : 'Not Attending'}`;
|
||||
|
||||
// Send to all admins who opted in
|
||||
const emailPromises = adminsToNotify.map((admin) => {
|
||||
const emailPromises = adminsToNotify.map((admin: { email: string | null }) => {
|
||||
if (!admin.email) return Promise.resolve(null);
|
||||
return resend.emails.send({
|
||||
from: process.env.FROM_EMAIL || 'noreply@themoyos.co.za',
|
||||
|
||||
@@ -67,10 +67,7 @@ export async function PUT(
|
||||
// Verify ownership - guests can only edit their own tributes
|
||||
// Note: guestId can be null for admin-created tributes, so check both
|
||||
if (existing.guestId && existing.guestId !== session.guestId) {
|
||||
audit.unauthorizedAccess("guest", "tribute", "edit", session.guestId, {
|
||||
attemptedTributeId: id,
|
||||
actualOwnerId: existing.guestId,
|
||||
});
|
||||
audit.accessDenied("guest", "tribute", session.guestId);
|
||||
return NextResponse.json(
|
||||
{ error: "You can only edit your own tributes" },
|
||||
{ status: 403 }
|
||||
|
||||
@@ -28,12 +28,13 @@ export async function GET(request: NextRequest) {
|
||||
guestName: true,
|
||||
dedication: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (format === 'wedding') {
|
||||
// Transform data to match the format expected by the wedding page
|
||||
const transformed = tributes.map((tribute: { id: string; guestName: string; dedication: string; createdAt: Date }) => ({
|
||||
const transformed = tributes.map((tribute: { id: string; guestName: string; dedication: string; createdAt: Date; updatedAt: Date }) => ({
|
||||
id: tribute.id,
|
||||
name: tribute.guestName,
|
||||
message: tribute.dedication,
|
||||
@@ -44,11 +45,14 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Return original format for dashboard
|
||||
const formatted = tributes.map((tribute: { id: string; guestName: string; dedication: string; createdAt: Date }) => ({
|
||||
const formatted = tributes.map((tribute: { id: string; guestName: string; dedication: string; createdAt: Date; updatedAt: Date; guestId: string | null }) => ({
|
||||
id: tribute.id,
|
||||
guestName: tribute.guestName,
|
||||
dedication: tribute.dedication,
|
||||
timestamp: tribute.createdAt.toISOString(),
|
||||
updatedAt: tribute.updatedAt.toISOString(),
|
||||
guestId: tribute.guestId,
|
||||
isEdited: tribute.updatedAt.getTime() > tribute.createdAt.getTime() + 1000, // 1 second buffer
|
||||
}));
|
||||
|
||||
return NextResponse.json(formatted);
|
||||
|
||||
@@ -200,6 +200,8 @@ export function AdminDashboard({
|
||||
|
||||
// Delete confirmation dialogs state
|
||||
const [deleteTributeDialog, setDeleteTributeDialog] = useState<{ open: boolean; tribute: any | null }>({ open: false, tribute: null });
|
||||
const [editTributeDialog, setEditTributeDialog] = useState<{ open: boolean; tribute: any | null }>({ open: false, tribute: null });
|
||||
const [editingTribute, setEditingTribute] = useState(false);
|
||||
const [addTributeDialogOpen, setAddTributeDialogOpen] = useState(false);
|
||||
const [creatingTribute, setCreatingTribute] = useState(false);
|
||||
const [newTribute, setNewTribute] = useState<{ guestName: string; dedication: string }>({
|
||||
@@ -4559,6 +4561,149 @@ export function AdminDashboard({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Tribute Dialog */}
|
||||
<Dialog
|
||||
open={editTributeDialog.open}
|
||||
onOpenChange={(open) => {
|
||||
setEditTributeDialog({ open, tribute: null });
|
||||
if (!open) {
|
||||
setNewTribute({ guestName: "", dedication: "" });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-heading text-wedding-evergreen">Edit Tribute</DialogTitle>
|
||||
<DialogDescription className="font-body">
|
||||
Update the tribute message and posted by name.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="font-body">Posted by</Label>
|
||||
<Input
|
||||
value={editTributeDialog.tribute?.guestName || ""}
|
||||
onChange={(e) => setEditTributeDialog(prev => ({
|
||||
...prev,
|
||||
tribute: prev.tribute ? { ...prev.tribute, guestName: e.target.value } : null
|
||||
}))}
|
||||
placeholder="e.g. The Moyo Family • In loving memory"
|
||||
className="font-body"
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="font-body">Dedication</Label>
|
||||
<Textarea
|
||||
value={editTributeDialog.tribute?.dedication || ""}
|
||||
onChange={(e) => setEditTributeDialog(prev => ({
|
||||
...prev,
|
||||
tribute: prev.tribute ? { ...prev.tribute, dedication: e.target.value } : null
|
||||
}))}
|
||||
placeholder="Write a short tribute (10–500 characters)…"
|
||||
className="min-h-[140px] font-body"
|
||||
maxLength={500}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === " ") e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-end text-[11px] text-wedding-moss/70 font-body">
|
||||
{(editTributeDialog.tribute?.dedication || "").length}/500
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setEditTributeDialog({ open: false, tribute: null })}
|
||||
disabled={editingTribute}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const tribute = editTributeDialog.tribute;
|
||||
if (!tribute || !tribute.id) return;
|
||||
|
||||
const guestName = tribute.guestName?.trim();
|
||||
const dedication = tribute.dedication?.trim();
|
||||
|
||||
if (!guestName) {
|
||||
toast({
|
||||
variant: "error",
|
||||
title: "Missing name",
|
||||
description: "Please enter who this tribute is posted by.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!dedication || dedication.length < 10) {
|
||||
toast({
|
||||
variant: "error",
|
||||
title: "Dedication too short",
|
||||
description: "Please write at least 10 characters.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setEditingTribute(true);
|
||||
try {
|
||||
const csrfToken = await getCSRFToken();
|
||||
if (!csrfToken) {
|
||||
toast({
|
||||
variant: "error",
|
||||
title: "Security error",
|
||||
description: "Failed to get CSRF token. Please refresh and try again.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/admin/tributes/${tribute.id}`, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-csrf-token': csrfToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
guestName,
|
||||
dedication,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || 'Failed to update tribute');
|
||||
}
|
||||
|
||||
await fetchTributesData(false);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Tribute updated successfully",
|
||||
});
|
||||
setEditTributeDialog({ open: false, tribute: null });
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "error",
|
||||
title: "Failed to update tribute",
|
||||
description: e instanceof Error ? e.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
setEditingTribute(false);
|
||||
}
|
||||
}}
|
||||
disabled={editingTribute}
|
||||
>
|
||||
{editingTribute ? "Updating…" : "Update Tribute"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{tributesLoading ? (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
@@ -4665,20 +4810,33 @@ export function AdminDashboard({
|
||||
<Flame className="h-4 w-4 text-orange-500" />
|
||||
<span className="text-sm font-medium text-wedding-evergreen">{tribute.guestName}</span>
|
||||
<span className="text-xs text-wedding-moss/60">• {tribute.timeAgo || 'recently'}</span>
|
||||
{tribute.isEdited && (
|
||||
<Badge variant="outline" className="text-xs">Edited</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-wedding-ink leading-relaxed italic">"{tribute.dedication}"</p>
|
||||
<p className="text-xs text-wedding-moss/60 mt-2">
|
||||
{tribute.date ? new Date(tribute.date).toLocaleString() : tribute.timestamp}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-red-500 hover:text-red-700 hover:bg-red-50 ml-4"
|
||||
onClick={() => setDeleteTributeDialog({ open: true, tribute })}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex gap-2 ml-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-wedding-evergreen hover:text-wedding-evergreen hover:bg-wedding-sage/10"
|
||||
onClick={() => setEditTributeDialog({ open: true, tribute })}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => setDeleteTributeDialog({ open: true, tribute })}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
@@ -14,11 +14,18 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Guest } from "@/types";
|
||||
import { CheckCircle2, XCircle } from "lucide-react";
|
||||
|
||||
interface AdminRSVPOnBehalfGuest {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
inviteCode?: string;
|
||||
[key: string]: any; // Allow additional properties
|
||||
}
|
||||
|
||||
interface AdminRSVPOnBehalfProps {
|
||||
guest: Guest;
|
||||
guest: AdminRSVPOnBehalfGuest;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Shield, UserCog, Bell, Image, Music, MessageSquare, Calendar, Settings as SettingsIcon, Users } from "lucide-react";
|
||||
import { Shield, UserCog, Bell, Image, Music, MessageSquare, Calendar, Settings as SettingsIcon, Users, Mail } from "lucide-react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -27,6 +27,9 @@ export function AdminSettings() {
|
||||
const [profanityFilter, setProfanityFilter] = useState(true);
|
||||
const [adminRole, setAdminRole] = useState("super_admin"); // 'super_admin' | 'sub_admin'
|
||||
const [emailNotifications, setEmailNotifications] = useState(true);
|
||||
const [receiveRsvpNotifications, setReceiveRsvpNotifications] = useState(true);
|
||||
const [loadingPreferences, setLoadingPreferences] = useState(true);
|
||||
const [savingPreferences, setSavingPreferences] = useState(false);
|
||||
const [publicGallery, setPublicGallery] = useState(true);
|
||||
const [guestBookEnabled, setGuestBookEnabled] = useState(true);
|
||||
const [musicRequestsEnabled, setMusicRequestsEnabled] = useState(true);
|
||||
@@ -37,6 +40,88 @@ export function AdminSettings() {
|
||||
const [venueName, setVenueName] = useState("The Grand Ballroom");
|
||||
const [venueAddress, setVenueAddress] = useState("123 Wedding Lane, Romance City");
|
||||
|
||||
// Load email preferences on mount
|
||||
useEffect(() => {
|
||||
const loadEmailPreferences = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/settings/email-preferences", {
|
||||
credentials: "include",
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success && data.preferences) {
|
||||
setReceiveRsvpNotifications(data.preferences.receiveRsvpNotifications ?? true);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load email preferences:", error);
|
||||
} finally {
|
||||
setLoadingPreferences(false);
|
||||
}
|
||||
};
|
||||
loadEmailPreferences();
|
||||
}, []);
|
||||
|
||||
// Save RSVP email preferences
|
||||
const handleSaveRsvpEmailPreferences = async () => {
|
||||
setSavingPreferences(true);
|
||||
try {
|
||||
const csrfToken = await getCSRFToken();
|
||||
if (!csrfToken) {
|
||||
toast({
|
||||
variant: "error",
|
||||
title: "Security Error",
|
||||
description: "Failed to get CSRF token. Please refresh and try again.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch("/api/admin/settings/email-preferences", {
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-csrf-token": csrfToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
receiveRsvpNotifications,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || "Failed to save preferences");
|
||||
}
|
||||
|
||||
toast({
|
||||
variant: "success",
|
||||
title: "Preferences Saved",
|
||||
description: "Your RSVP email notification preferences have been updated.",
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "error",
|
||||
title: "Failed to Save",
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
setSavingPreferences(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getCSRFToken = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/csrf", { credentials: "include" });
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return data.token;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to get CSRF token:", error);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSaveSettings = () => {
|
||||
// In a real app, this would send settings to a backend API
|
||||
const settings = {
|
||||
@@ -46,6 +131,7 @@ export function AdminSettings() {
|
||||
profanityFilter,
|
||||
adminRole,
|
||||
emailNotifications,
|
||||
receiveRsvpNotifications,
|
||||
publicGallery,
|
||||
guestBookEnabled,
|
||||
musicRequestsEnabled,
|
||||
@@ -135,6 +221,39 @@ export function AdminSettings() {
|
||||
onCheckedChange={setEmailNotifications}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Email Preferences */}
|
||||
<Card variant="glass">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-heading flex items-center gap-2"><Mail className="h-5 w-5" /> Email Preferences</CardTitle>
|
||||
<CardDescription className="font-body">Manage your email notification preferences.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="rsvp-email-notifications" className="font-body">Receive RSVP Notifications</Label>
|
||||
<p className="text-sm text-wedding-moss/70 font-body">
|
||||
Get notified via email when guests submit their RSVP
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="rsvp-email-notifications"
|
||||
checked={receiveRsvpNotifications}
|
||||
onCheckedChange={(checked) => {
|
||||
setReceiveRsvpNotifications(checked);
|
||||
// Auto-save when toggled
|
||||
setTimeout(() => {
|
||||
handleSaveRsvpEmailPreferences();
|
||||
}, 100);
|
||||
}}
|
||||
disabled={loadingPreferences || savingPreferences}
|
||||
/>
|
||||
</div>
|
||||
{savingPreferences && (
|
||||
<p className="text-xs text-wedding-moss/60 font-body italic">Saving preferences...</p>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-guests" className="font-body">Maximum Guests</Label>
|
||||
|
||||
@@ -11,15 +11,26 @@ import { realtimeManager, RealtimeEvent } from "@/lib/realtime";
|
||||
import { useNetworkStatus } from "@/components/network-status";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { AlertCircle, Edit, X } from "lucide-react";
|
||||
import ScrollingTestimonials from "@/components/features/guestbook/scrolling-testimonials";
|
||||
import EnhancedScrollingTestimonials from "@/components/features/guestbook/enhanced-scrolling-testimonials";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface GuestbookMessage {
|
||||
id: string;
|
||||
guestName: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
updatedAt?: string;
|
||||
guestId?: string;
|
||||
isEdited?: boolean;
|
||||
guest?: {
|
||||
name: string;
|
||||
avatar?: string;
|
||||
@@ -36,6 +47,9 @@ export function GuestbookFeed() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { isOnline } = useNetworkStatus();
|
||||
const { toast } = useToast();
|
||||
const [editingMessage, setEditingMessage] = useState<GuestbookMessage | null>(null);
|
||||
const [editMessageText, setEditMessageText] = useState("");
|
||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||
|
||||
// Load initial messages
|
||||
useEffect(() => {
|
||||
@@ -333,21 +347,174 @@ export function GuestbookFeed() {
|
||||
{isLoading ? (
|
||||
<GuestbookSkeleton />
|
||||
) : (
|
||||
<EnhancedScrollingTestimonials
|
||||
data={messages.map((msg: any) => ({
|
||||
id: msg.id,
|
||||
name: msg.guestName,
|
||||
message: msg.message,
|
||||
timestamp: msg.timestamp,
|
||||
avatar: msg.guest?.avatar || msg.photo || undefined,
|
||||
}))}
|
||||
variant="guestbook"
|
||||
apiEndpoint="/api/guestbook"
|
||||
batchSize={20}
|
||||
pollInterval={5 * 60 * 1000}
|
||||
/>
|
||||
<>
|
||||
{/* My Messages Section */}
|
||||
{guest && messages.filter((msg) => msg.guestId === guest.id).length > 0 && (
|
||||
<div className="space-y-4 mb-6">
|
||||
<h4 className="font-heading text-sm text-wedding-ink/80">My Messages</h4>
|
||||
{messages
|
||||
.filter((msg) => msg.guestId === guest.id)
|
||||
.map((msg) => (
|
||||
<GlassCard key={msg.id} className="p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-heading text-sm text-wedding-evergreen">{msg.guestName}</span>
|
||||
{msg.isEdited && (
|
||||
<span className="text-xs text-wedding-moss/60 italic">(edited)</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-wedding-ink/90 font-body leading-relaxed">"{msg.message}"</p>
|
||||
<p className="text-xs text-wedding-moss/60 mt-2">
|
||||
{new Date(msg.timestamp).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-wedding-evergreen hover:text-wedding-evergreen hover:bg-wedding-sage/10"
|
||||
onClick={() => {
|
||||
setEditingMessage(msg);
|
||||
setEditMessageText(msg.message);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* All Messages - Scrolling Marquee */}
|
||||
<EnhancedScrollingTestimonials
|
||||
data={messages.map((msg: any) => ({
|
||||
id: msg.id,
|
||||
name: msg.guestName,
|
||||
message: msg.message,
|
||||
timestamp: msg.timestamp,
|
||||
avatar: msg.guest?.avatar || msg.photo || undefined,
|
||||
}))}
|
||||
variant="guestbook"
|
||||
apiEndpoint="/api/guestbook"
|
||||
batchSize={20}
|
||||
pollInterval={5 * 60 * 1000}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit Message Dialog */}
|
||||
<Dialog
|
||||
open={!!editingMessage}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditingMessage(null);
|
||||
setEditMessageText("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-heading text-wedding-evergreen">Edit Your Message</DialogTitle>
|
||||
<DialogDescription className="font-body">
|
||||
Update your guestbook message
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-body text-wedding-ink">Message</label>
|
||||
<Textarea
|
||||
value={editMessageText}
|
||||
onChange={(e) => setEditMessageText(e.target.value)}
|
||||
placeholder="Write your message..."
|
||||
className="min-h-[100px] font-body resize-none"
|
||||
disabled={isSavingEdit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditingMessage(null);
|
||||
setEditMessageText("");
|
||||
}}
|
||||
disabled={isSavingEdit}
|
||||
className="font-body"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!editingMessage || !editMessageText.trim()) return;
|
||||
|
||||
setIsSavingEdit(true);
|
||||
try {
|
||||
// Get CSRF token
|
||||
const csrfResponse = await fetch("/api/csrf", { credentials: "include" });
|
||||
const csrfData = await csrfResponse.json();
|
||||
const csrfToken = csrfData.token;
|
||||
|
||||
if (!csrfToken) {
|
||||
throw new Error("Failed to get CSRF token");
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/guestbook/${editingMessage.id}`, {
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-csrf-token": csrfToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: editMessageText.trim(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || "Failed to update message");
|
||||
}
|
||||
|
||||
const updated = await response.json();
|
||||
|
||||
// Update local state
|
||||
setMessages(prev =>
|
||||
prev.map(msg =>
|
||||
msg.id === editingMessage.id
|
||||
? { ...msg, message: updated.entry.message, isEdited: true, updatedAt: updated.entry.updatedAt }
|
||||
: msg
|
||||
)
|
||||
);
|
||||
|
||||
toast({
|
||||
variant: "success",
|
||||
title: "Message Updated",
|
||||
description: "Your guestbook message has been updated successfully.",
|
||||
});
|
||||
|
||||
setEditingMessage(null);
|
||||
setEditMessageText("");
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "error",
|
||||
title: "Failed to Update",
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
setIsSavingEdit(false);
|
||||
}
|
||||
}}
|
||||
disabled={isSavingEdit || !editMessageText.trim()}
|
||||
className="font-body"
|
||||
>
|
||||
{isSavingEdit ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,11 +8,19 @@ import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Sparkles, X } from "lucide-react";
|
||||
import { Sparkles, X, Edit } from "lucide-react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import ScrollingTestimonials from "@/components/features/guestbook/scrolling-testimonials";
|
||||
import EnhancedScrollingTestimonials from "@/components/features/guestbook/enhanced-scrolling-testimonials";
|
||||
import { realtimeManager, RealtimeEvent } from "@/lib/realtime";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
const fetcher = async (url: string) => {
|
||||
try {
|
||||
@@ -40,6 +48,10 @@ export function TributeWall() {
|
||||
const [dedication, setDedication] = useState("");
|
||||
const [isLighting, setIsLighting] = useState(false);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingTribute, setEditingTribute] = useState<any | null>(null);
|
||||
const [editDedication, setEditDedication] = useState("");
|
||||
const [editGuestName, setEditGuestName] = useState("");
|
||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||
|
||||
// Subscribe to real-time updates for deletions
|
||||
useEffect(() => {
|
||||
@@ -449,6 +461,55 @@ export function TributeWall() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* My Tributes Section */}
|
||||
{guest && Array.isArray(candles) && candles.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<h3 className="font-heading text-lg text-wedding-ink/80 text-center">
|
||||
My Tributes
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{candles
|
||||
.filter((candle: any) => candle.guestId === guest.id)
|
||||
.map((candle: any) => (
|
||||
<GlassCard key={candle.id} className="p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xl">🕯️</span>
|
||||
<span className="font-heading text-sm text-wedding-evergreen">{candle.guestName}</span>
|
||||
{candle.isEdited && (
|
||||
<span className="text-xs text-wedding-moss/60 italic">(edited)</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-wedding-ink/90 font-body leading-relaxed italic">"{candle.dedication}"</p>
|
||||
<p className="text-xs text-wedding-moss/60 mt-2">
|
||||
{new Date(candle.timestamp).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-wedding-evergreen hover:text-wedding-evergreen hover:bg-wedding-sage/10"
|
||||
onClick={() => {
|
||||
setEditingTribute(candle);
|
||||
setEditDedication(candle.dedication);
|
||||
setEditGuestName(candle.guestName);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Tribute Wall */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@@ -473,6 +534,155 @@ export function TributeWall() {
|
||||
pollInterval={5 * 60 * 1000}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Edit Tribute Dialog */}
|
||||
<Dialog
|
||||
open={!!editingTribute}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditingTribute(null);
|
||||
setEditDedication("");
|
||||
setEditGuestName("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-heading text-wedding-evergreen">Edit Your Tribute</DialogTitle>
|
||||
<DialogDescription className="font-body">
|
||||
Update your tribute message
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-body text-wedding-ink">Your Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editGuestName}
|
||||
onChange={(e) => setEditGuestName(e.target.value)}
|
||||
placeholder="Your name"
|
||||
className="w-full px-3 py-2 border border-wedding-sage/20 rounded-lg font-body"
|
||||
disabled={isSavingEdit}
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-body text-wedding-ink">Dedication</label>
|
||||
<Textarea
|
||||
value={editDedication}
|
||||
onChange={(e) => setEditDedication(e.target.value)}
|
||||
placeholder="Write your tribute..."
|
||||
className="min-h-[120px] font-body resize-none"
|
||||
disabled={isSavingEdit}
|
||||
maxLength={500}
|
||||
/>
|
||||
<div className="flex justify-end text-[11px] text-wedding-moss/70 font-body">
|
||||
{editDedication.length}/500
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditingTribute(null);
|
||||
setEditDedication("");
|
||||
setEditGuestName("");
|
||||
}}
|
||||
disabled={isSavingEdit}
|
||||
className="font-body"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!editingTribute || !editDedication.trim() || !editGuestName.trim()) return;
|
||||
|
||||
if (editDedication.length < 10) {
|
||||
toast({
|
||||
variant: "error",
|
||||
title: "Dedication too short",
|
||||
description: "Please write at least 10 characters.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingEdit(true);
|
||||
try {
|
||||
// Get CSRF token
|
||||
const csrfResponse = await fetch("/api/csrf", { credentials: "include" });
|
||||
const csrfData = await csrfResponse.json();
|
||||
const csrfToken = csrfData.token;
|
||||
|
||||
if (!csrfToken) {
|
||||
throw new Error("Failed to get CSRF token");
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/tribute/${editingTribute.id}`, {
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-csrf-token": csrfToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
guestName: editGuestName.trim(),
|
||||
dedication: editDedication.trim(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || "Failed to update tribute");
|
||||
}
|
||||
|
||||
const updated = await response.json();
|
||||
|
||||
// Update SWR cache
|
||||
mutate((current: any) => {
|
||||
if (!Array.isArray(current)) return current;
|
||||
return current.map((candle: any) =>
|
||||
candle.id === editingTribute.id
|
||||
? {
|
||||
...candle,
|
||||
guestName: updated.tribute.guestName,
|
||||
dedication: updated.tribute.dedication,
|
||||
isEdited: true,
|
||||
updatedAt: updated.tribute.updatedAt,
|
||||
}
|
||||
: candle
|
||||
);
|
||||
}, false);
|
||||
|
||||
toast({
|
||||
variant: "success",
|
||||
title: "Tribute Updated",
|
||||
description: "Your tribute has been updated successfully.",
|
||||
});
|
||||
|
||||
setEditingTribute(null);
|
||||
setEditDedication("");
|
||||
setEditGuestName("");
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "error",
|
||||
title: "Failed to Update",
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
setIsSavingEdit(false);
|
||||
}
|
||||
}}
|
||||
disabled={isSavingEdit || !editDedication.trim() || !editGuestName.trim() || editDedication.length < 10}
|
||||
className="font-body"
|
||||
>
|
||||
{isSavingEdit ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export interface Guest {
|
||||
id: string;
|
||||
inviteCode: string;
|
||||
inviteCode: string; // Required for most operations, but can be undefined in some contexts
|
||||
name: string;
|
||||
maxPax: number;
|
||||
hasResponded?: boolean; // Computed from isAttending !== null
|
||||
|
||||
Reference in New Issue
Block a user