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:
2026-01-24 03:01:38 +02:00
parent 9fc9c9c32c
commit dab81a6a60
19 changed files with 739 additions and 62 deletions

View File

@@ -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;
}

View File

@@ -255,7 +255,7 @@ function RSVPContent() {
dietaryRestrictions: guest.dietaryRestrictions ?? null,
confirmationCode: guest.confirmationCode ?? null,
}}
inviteCode={guest.inviteCode}
inviteCode={guest.inviteCode || ''}
/>
</div>
</div>

View File

@@ -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({

View File

@@ -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 }
);

View File

@@ -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 || "",

View File

@@ -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,

View File

@@ -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 }
);

View File

@@ -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(),
};

View File

@@ -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 }

View File

@@ -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) {

View File

@@ -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',

View File

@@ -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 }

View File

@@ -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);

View File

@@ -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 (10500 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>
))

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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