+
+
+ Vibely
+
+
+ Designed for the moments that matter. The premium event photo
+ sharing platform for modern creators.
+
-
-
- Privacy
-
-
- Terms
-
-
- Support
-
-
- Blog
-
+
+
+
+ Product
+
+
+ -
+
+ Features
+
+
+ -
+
+ Pricing
+
+
+ -
+
+ Case Studies
+
+
+
+
+
+
+ Support
+
+
+ -
+
+ Help Center
+
+
+ -
+
+ Safety
+
+
+ -
+
+ Terms
+
+
+
+
+
+
Social
+
+ -
+
+ Instagram
+
+
+ -
+
+ TikTok
+
+
+ -
+
+ Twitter
+
+
+
+
-
- © {new Date().getFullYear()} Vibely Inc. All rights reserved.
+
+
+
+ © {new Date().getFullYear()} VIBELY INC. ALL RIGHTS RESERVED.
+
+
+
+ public
+
+
+ shield
+
-
+ >
);
}
diff --git a/apps/vibely-web/app/pricing/page.tsx b/apps/vibely-web/app/pricing/page.tsx
deleted file mode 100644
index 6f45a46..0000000
--- a/apps/vibely-web/app/pricing/page.tsx
+++ /dev/null
@@ -1,195 +0,0 @@
-// ============================================================
-// apps/web/app/pricing/page.tsx
-// ============================================================
-// Dedicated pricing page requested by the user.
-// Shows 3 tiers: Go, Plus, Pro in a neumorphic design.
-// ============================================================
-import { NavBarHome } from "@/components/layout/NavBarHome";
-import { BottomCTA } from "@/components/landing/BottomCTA";
-import Link from "next/link";
-import { Check, MirrorRectangular } from "lucide-react";
-
-export const metadata = {
- title: "Pricing | Vibely",
- description: "Simple, transparent pricing for any event size.",
-};
-
-const PLANS = [
- {
- name: "Go",
- price: "Free",
- description: "Perfect for small get-togethers and casual hangouts.",
- features: [
- "Up to 50 guests",
- "Standard quality photos",
- "Event active for 24 hours",
- "Basic gallery layout",
- ],
- cta: "Start for Free",
- href: "/signup",
- isPopular: false,
- },
- {
- name: "Plus",
- price: "$19",
- period: "/event",
- description: "Ideal for weddings, parties, and larger celebrations.",
- features: [
- "Up to 500 guests",
- "High-Res original quality",
- "Event active for 1 month",
- "Live slideshow feature",
- "Bulk download for host",
- ],
- cta: "Get Plus",
- href: "/signup",
- isPopular: true,
- },
- {
- name: "Pro",
- price: "$49",
- period: "/month",
- description: "For professional photographers and event planners.",
- features: [
- "Unlimited guests",
- "Unlimited active events",
- "Custom branding & colors",
- "Analytics dashboard",
- "Priority customer support",
- "Physical prints integration",
- ],
- cta: "Start Pro Trial",
- href: "/signup",
- isPopular: false,
- },
-];
-
-export default function PricingPage() {
- return (
-
-
-
-
- {/* Header */}
-
-
- Simple pricing,{" "}
- no surprises.
-
-
- Whether you're hosting a small dinner or a massive wedding,
- we've got a plan that perfectly captures your vibe.
-
-
-
- {/* Pricing Cards Grid */}
-
- {PLANS.map((plan) => (
-
- {plan.isPopular && (
-
-
- Most Popular
-
-
- )}
-
-
-
- {plan.name}
-
-
{plan.description}
-
-
-
-
- {plan.price}
-
- {plan.period && (
-
- {plan.period}
-
- )}
-
-
-
- {plan.features.map((feature) => (
- -
-
-
- {feature}
-
-
- ))}
-
-
-
- {plan.cta}
-
-
- ))}
-
-
-
-
-
- {/* Footer */}
-
-
- );
-}
diff --git a/apps/vibely-web/app/vault/page.tsx b/apps/vibely-web/app/vault/page.tsx
deleted file mode 100644
index e06d8c5..0000000
--- a/apps/vibely-web/app/vault/page.tsx
+++ /dev/null
@@ -1,268 +0,0 @@
-"use client";
-
-// ============================================================
-// apps/web/app/vault/page.tsx
-// ============================================================
-
-import Link from "next/link";
-import { useRouter } from "next/navigation";
-import { useState } from "react";
-import { useVault } from "@/hooks/useVault";
-import Image from "next/image";
-
-export default function VaultPage() {
- const router = useRouter();
- const { groups, total, isLoading, error, unsave } = useVault();
- const [view, setView] = useState<"grouped" | "grid">("grouped");
-
- return (
-
- {/* Header */}
-
-
-
-
My Vault
-
- {total} saved photo{total !== 1 ? "s" : ""}
-
-
- {/* View toggle */}
-
- {(["grouped", "grid"] as const).map((v) => (
-
- ))}
-
-
-
-
-
- {/* Loading */}
- {isLoading && (
-
- {Array.from({ length: 8 }).map((_, i) => (
-
- ))}
-
- )}
-
- {/* Error */}
- {error && !isLoading && (
-
- )}
-
- {/* Empty state */}
- {!isLoading && !error && total === 0 && (
-
-
🔖
-
- Your vault is empty
-
-
- Save photos from your events by hovering over them and clicking
- the bookmark icon.
-
-
- Browse events →
-
-
- )}
-
- {/* Grouped view */}
- {!isLoading && view === "grouped" && groups.length > 0 && (
-
- {groups.map((group) => (
-
-
-
-
- {group.event.title}
-
-
- {group.photos.length} saved photo
- {group.photos.length !== 1 ? "s" : ""}
-
-
-
- View event →
-
-
-
-
- {group.photos.map((entry) => (
- unsave(entry.photo.id)}
- onOpen={() => router.push(`/photos/${entry.photo.id}`)}
- />
- ))}
-
-
- ))}
-
- )}
-
- {/* Flat grid view */}
- {!isLoading && view === "grid" && groups.length > 0 && (
-
- {groups
- .flatMap((g) => g.photos)
- .map((entry) => (
- unsave(entry.photo.id)}
- onOpen={() => router.push(`/photos/${entry.photo.id}`)}
- />
- ))}
-
- )}
-
-
- );
-}
-
-// ── Vault Card ────────────────────────────────────────────────
-
-function VaultCard({
- entry,
- onUnsave,
- onOpen,
-}: {
- entry: {
- vault_entry_id: string;
- photo: {
- id: string;
- thumbnail_url: string;
- original_filename: string;
- preview_url: string;
- fallback_url: string | null;
- };
- };
- onUnsave: () => void;
- onOpen: () => void;
-}) {
- const [src, setSrc] = useState(entry.photo.thumbnail_url);
- const [triedFallback, setTriedFallback] = useState(false);
- const [imgError, setImgError] = useState(false);
-
- return (
-
- {!imgError ? (
-
{
- if (!triedFallback && entry.photo.fallback_url) {
- setTriedFallback(true);
- setSrc(entry.photo.fallback_url);
- return;
- }
- setImgError(true);
- }}
- fill
- />
- ) : (
-
- )}
- {/* Hover overlay */}
-
-
- );
-}
diff --git a/apps/vibely-web/components/dashboard/MobileNav.tsx b/apps/vibely-web/components/dashboard/MobileNav.tsx
new file mode 100644
index 0000000..ae6ddd4
--- /dev/null
+++ b/apps/vibely-web/components/dashboard/MobileNav.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+
+export default function MobileNav() {
+ const pathname = usePathname();
+
+ const navItems = [
+ { href: "/dashboard", label: "Feed", icon: "grid_view" },
+ { href: "/dashboard/events", label: "Gallery", icon: "photo_library" },
+ { href: "/vault", label: "Vault", icon: "cloud_done" },
+ { href: "/dashboard/profile", label: "Profile", icon: "person" },
+ ];
+
+ return (
+
+ );
+}
diff --git a/apps/vibely-web/components/dashboard/Sidebar.tsx b/apps/vibely-web/components/dashboard/Sidebar.tsx
new file mode 100644
index 0000000..da03183
--- /dev/null
+++ b/apps/vibely-web/components/dashboard/Sidebar.tsx
@@ -0,0 +1,208 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { useAuth } from "@/context/AuthContext";
+import { useState, useEffect } from "react";
+import { createClient } from "@/lib/supabase/client";
+
+interface SidebarProps {
+ isCollapsed: boolean;
+ setIsCollapsed: (value: boolean) => void;
+}
+
+export default function Sidebar({ isCollapsed, setIsCollapsed }: SidebarProps) {
+ const pathname = usePathname();
+ const { signOut } = useAuth();
+ const [userName, setUserName] = useState("Host");
+ const [avatarUrl, setAvatarUrl] = useState
(null);
+
+ useEffect(() => {
+ const supabase = createClient();
+ supabase.auth.getUser().then(({ data: { user } }) => {
+ if (!user) return;
+
+ // Initial metadata check
+ if (user.user_metadata?.full_name) {
+ setUserName(user.user_metadata.full_name);
+ }
+ if (user.user_metadata?.avatar_url) {
+ setAvatarUrl(user.user_metadata.avatar_url);
+ }
+
+ // Fresh DB fetch for accuracy
+ supabase
+ .from("users")
+ .select("name, avatar_url")
+ .eq("id", user.id)
+ .single()
+ .then(({ data }) => {
+ if (data) {
+ if (data.name) setUserName(data.name);
+ if (data.avatar_url) setAvatarUrl(data.avatar_url);
+ }
+ });
+ });
+ }, []);
+
+ const navItems = [
+ { href: "/dashboard", label: "Dashboard", icon: "dashboard" },
+ { href: "/dashboard/events", label: "My Events", icon: "calendar_today" },
+ { href: "/vault", label: "Vault", icon: "auto_awesome_motion" },
+ { href: "/dashboard/analytics", label: "Analytics", icon: "leaderboard" },
+ { href: "/dashboard/settings", label: "Settings", icon: "settings" },
+ ];
+
+ const getInitials = (name: string) => {
+ return name
+ .split(" ")
+ .map((n) => n[0])
+ .slice(0, 2)
+ .join("")
+ .toUpperCase();
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/vibely-web/components/events/CreateEventCard.tsx b/apps/vibely-web/components/events/CreateEventCard.tsx
new file mode 100644
index 0000000..93f557d
--- /dev/null
+++ b/apps/vibely-web/components/events/CreateEventCard.tsx
@@ -0,0 +1,18 @@
+import { Plus } from "lucide-react";
+import Link from "next/link";
+
+export default function CreateEventCard() {
+ return (
+
+
+
+ Create New Event
+
+
+ );
+}
diff --git a/apps/vibely-web/components/events/EditEventForm.tsx b/apps/vibely-web/components/events/EditEventForm.tsx
new file mode 100644
index 0000000..c5ad111
--- /dev/null
+++ b/apps/vibely-web/components/events/EditEventForm.tsx
@@ -0,0 +1,345 @@
+"use client";
+
+import { useState, useRef } from "react";
+import Image from "next/image";
+import type { Tables } from "@repo/supabase/types";
+
+type UploadPermission = "open" | "restricted";
+
+interface EditEventFormProps {
+ event: Tables<"events">;
+ onUpdate: (
+ data: Partial>
+ ) => Promise<{ success: boolean; error?: string }>;
+ onDelete: () => Promise<{ success: boolean; error?: string }>;
+}
+
+export function EditEventForm({
+ event,
+ onUpdate,
+ onDelete,
+}: EditEventFormProps) {
+ const [form, setForm] = useState({
+ title: event.title,
+ description: event.description || "",
+ event_date: event.event_date
+ ? new Date(event.event_date).toISOString().slice(0, 16)
+ : "",
+ upload_permission: (event.upload_permission as UploadPermission) || "open",
+ status: event.status,
+ });
+
+ const [saving, setSaving] = useState(false);
+ const [deleting, setDeleting] = useState(false);
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(false);
+
+ // Cover image state
+ const [coverPreview, setCoverPreview] = useState(
+ event.cover_image_url || null
+ );
+ const [coverUploading, setCoverUploading] = useState(false);
+ const coverInputRef = useRef(null);
+
+ const handleSave = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!form.title.trim()) {
+ setError("Title is required");
+ return;
+ }
+ setSaving(true);
+ setError(null);
+ setSuccess(false);
+
+ const result = await onUpdate({
+ title: form.title.trim(),
+ description: form.description.trim() || undefined,
+ event_date: form.event_date
+ ? new Date(form.event_date).toISOString()
+ : undefined,
+ upload_permission: form.upload_permission,
+ status: form.status as Tables<"events">["status"],
+ });
+
+ setSaving(false);
+ if (result.success) {
+ setSuccess(true);
+ setTimeout(() => setSuccess(false), 3000);
+ } else {
+ setError(result.error || "Failed to save changes");
+ }
+ };
+
+ const handleCoverChange = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ // Show preview immediately
+ const reader = new FileReader();
+ reader.onload = (ev) => setCoverPreview(ev.target?.result as string);
+ reader.readAsDataURL(file);
+
+ setCoverUploading(true);
+ setError(null);
+
+ try {
+ // Step 1: Get signed URL
+ const initRes = await fetch(`/api/events/${event.id}/cover`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ content_type: file.type, file_size: file.size }),
+ });
+ const { upload_url, storage_key } = await initRes.json();
+ if (!initRes.ok) throw new Error("Failed to get upload URL");
+
+ // Step 2: Upload
+ const putRes = await fetch(upload_url, {
+ method: "PUT",
+ headers: { "Content-Type": file.type },
+ body: file,
+ });
+ if (!putRes.ok) throw new Error("Upload failed");
+
+ // Step 3: Save URL
+ const completeRes = await fetch(
+ `/api/events/${event.id}/cover/complete`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ storage_key }),
+ }
+ );
+ if (!completeRes.ok) throw new Error("Failed to save cover");
+
+ setSuccess(true);
+ setTimeout(() => setSuccess(false), 3000);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Cover upload failed");
+ setCoverPreview(event.cover_image_url); // revert preview
+ } finally {
+ setCoverUploading(false);
+ e.target.value = "";
+ }
+ };
+
+ const inputClasses =
+ "w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-white focus:outline-hidden focus:ring-2 focus:ring-primary/50 transition-all placeholder:text-on-surface-variant/40 hover:bg-white/10 text-sm";
+ const labelClasses =
+ "block text-[10px] font-black uppercase tracking-[0.2em] text-on-surface-variant mb-3 ml-1";
+
+ return (
+
+ );
+}
diff --git a/apps/vibely-web/components/events/EventActionBar.tsx b/apps/vibely-web/components/events/EventActionBar.tsx
new file mode 100644
index 0000000..9b9b928
--- /dev/null
+++ b/apps/vibely-web/components/events/EventActionBar.tsx
@@ -0,0 +1,54 @@
+interface EventActionBarProps {
+ onBulkSelect: () => void;
+ onDownloadAll: () => void;
+ onUpload: () => void;
+ title?: string;
+ subtitle?: string;
+}
+
+export function EventActionBar({
+ onBulkSelect,
+ onDownloadAll,
+ onUpload,
+ title = "Recent Uploads",
+ subtitle = "Photos showing the latest moments",
+}: EventActionBarProps) {
+ return (
+
+
+
+ {title}
+
+
{subtitle}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/vibely-web/components/events/EventCard.tsx b/apps/vibely-web/components/events/EventCard.tsx
index 4e4728f..b8b47b0 100644
--- a/apps/vibely-web/components/events/EventCard.tsx
+++ b/apps/vibely-web/components/events/EventCard.tsx
@@ -1,138 +1,77 @@
-"use client";
-
-// ============================================================
-// apps/web/components/events/EventCard.tsx
-// ============================================================
-// Displays a compact event summary in the dashboard grid.
-// Shows: cover image/placeholder, title, date, member count,
-// status badge, and the user's role.
-// ============================================================
-
import Link from "next/link";
-import Image from "next/image";
-import {
- formatEventDate,
- relativeTime,
- isEventExpired,
-} from "@shared/utils/invite";
-import type { EventWithRole } from "@/hooks/useEvents";
+import { isEventExpired } from "@shared/utils/invite";
+import EventMemberStack from "./EventMemberStack";
interface EventCardProps {
- event: EventWithRole;
- onDelete?: (id: string) => void;
+ id: string;
+ title: string;
+ event_date: string;
+ description?: string | null;
+ cover_image_url?: string | null;
+ status: string;
+ expires_at?: string | null;
}
-const STATUS_STYLES = {
- active: "bg-emerald-50 text-emerald-700 border-emerald-100",
- expired: "bg-gray-50 text-gray-500 border-gray-100",
- archived: "bg-amber-50 text-amber-700 border-amber-100",
-} as const;
-
-const ROLE_LABEL = {
- host: "Host",
- contributor: "Member",
- viewer: "Viewer",
-} as const;
-
-export function EventCard({ event, onDelete }: EventCardProps) {
- const expired = isEventExpired(event.expires_at);
- const displayStatus = (
- expired && event.status === "active" ? "expired" : event.status
- ) as keyof typeof STATUS_STYLES;
+export default function EventCard({
+ id,
+ title,
+ event_date,
+ // description,
+ cover_image_url,
+ status,
+ expires_at,
+}: EventCardProps) {
+ const expired = isEventExpired(expires_at || "") || status !== "active";
+ const coverImg =
+ cover_image_url ||
+ "https://images.unsplash.com/photo-1492684223066-81342ee5ff30?q=80&w=2070&auto=format&fit=crop";
return (
-
- {/* Cover image or gradient placeholder */}
-
- {event.cover_image_url && (
-
- )}
-
- {/* Status badge overlaid on the image */}
-
- {displayStatus.charAt(0).toUpperCase() + displayStatus.slice(1)}
-
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

- {/* Role badge */}
-
- {ROLE_LABEL[event.user_role]}
-
-
-
- {/* Content */}
-
-
-
- {event.title}
-
-
-
-
- {formatEventDate(event.event_date)}
-
-
- {event.description && (
-
- {event.description}
-
- )}
+ {/* Glassy Overlay Container */}
+
+
+ {expired ? "Archived" : "Live"}
+
- {/* Footer row */}
-
-
- {/* Calendar icon */}
-
- {relativeTime(event.event_date)}
+ {/* Event Details - Glassmorphic Card Style */}
+
+
+ {title}
+
+
+
+ calendar_today
+
+
+ {new Date(event_date).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ })}
+
- {/* Action buttons — only visible on hover */}
-
-
- View
-
-
- {event.user_role === "host" && (
- <>
-
- Edit
-
-
- >
- )}
+
+
+
+
+ arrow_forward
+
+
-
+
);
}
diff --git a/apps/vibely-web/components/events/EventGallery.tsx b/apps/vibely-web/components/events/EventGallery.tsx
new file mode 100644
index 0000000..4bc6601
--- /dev/null
+++ b/apps/vibely-web/components/events/EventGallery.tsx
@@ -0,0 +1,109 @@
+"use client";
+
+import VaultCard from "@/components/vault/VaultCard";
+import type { GalleryPhoto } from "@/hooks/usePhotos";
+
+interface EventGalleryProps {
+ photos: GalleryPhoto[];
+ eventTitle: string;
+ isLoading: boolean;
+ onOpenPhoto: (photo: GalleryPhoto) => void;
+ pagination: {
+ page: number;
+ total_pages: number;
+ has_next: boolean;
+ has_prev: boolean;
+ } | null;
+ onPageChange: (page: number) => void;
+ onSavePhoto: (photoId: string) => void;
+ onUnsavePhoto: (photoId: string) => void;
+}
+
+export function EventGallery({
+ photos,
+ eventTitle,
+ isLoading,
+ onOpenPhoto,
+ pagination,
+ onPageChange,
+ onSavePhoto,
+ onUnsavePhoto,
+}: EventGalleryProps) {
+ if (isLoading) {
+ return (
+
+ {Array.from({ length: 9 }).map((_, i) => (
+
+ ))}
+
+ );
+ }
+
+ if (photos.length === 0) {
+ return (
+
+
+
+
+ No Photos Yet
+
+
+ Be the first to upload and share memories!
+
+
+ );
+ }
+
+ return (
+ <>
+
+ {photos.map((photo) => (
+ onOpenPhoto(photo)}
+ onSave={() => onSavePhoto(photo.id)}
+ onUnsave={() => onUnsavePhoto(photo.id)}
+ />
+ ))}
+
+
+ {/* Pagination Load More Style */}
+ {pagination && pagination.has_next && (
+
+
+
+ )}
+ >
+ );
+}
diff --git a/apps/vibely-web/components/events/EventGrid.tsx b/apps/vibely-web/components/events/EventGrid.tsx
new file mode 100644
index 0000000..37e7492
--- /dev/null
+++ b/apps/vibely-web/components/events/EventGrid.tsx
@@ -0,0 +1,13 @@
+import { ReactNode } from "react";
+
+interface EventGridProps {
+ children: ReactNode;
+}
+
+export default function EventGrid({ children }: EventGridProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/vibely-web/components/events/EventHero.tsx b/apps/vibely-web/components/events/EventHero.tsx
new file mode 100644
index 0000000..d792403
--- /dev/null
+++ b/apps/vibely-web/components/events/EventHero.tsx
@@ -0,0 +1,110 @@
+import Image from "next/image";
+import Link from "next/link";
+import { formatEventDate, isEventExpired } from "@shared/utils/invite";
+
+interface EventHeroProps {
+ id: string;
+ title: string;
+ date: string;
+ location?: string | null;
+ guestCount: number;
+ coverImageUrl?: string | null;
+ status: string;
+ expiresAt?: string | null;
+ isHost: boolean;
+}
+
+export function EventHero({
+ title,
+ date,
+ location,
+ guestCount,
+ coverImageUrl,
+ status,
+ expiresAt,
+}: EventHeroProps) {
+ const expired = expiresAt ? isEventExpired(expiresAt) : false;
+ const isActive = status === "active" && !expired;
+
+ return (
+
+
+ {coverImageUrl ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {/* Back Button & Host Actions */}
+
+
+ arrow_back
+
+
+
+ {/* Glass Overlay Content */}
+
+
+
+
+
+ {isActive ? (
+
+ Active Event
+
+ ) : (
+
+ {expired ? "Ended" : "Inactive"}
+
+ )}
+
+
+
+ calendar_today
+
+ {formatEventDate(date)}
+
+
+
+
+ {title}
+
+
+
+ {location ? (
+ <>
+
+
+ location_on
+
+ {location}
+
+
+ >
+ ) : null}
+
+
+ group
+
+ {guestCount} guests
+
+
+
+
+
+ );
+}
diff --git a/apps/vibely-web/components/events/EventMemberStack.tsx b/apps/vibely-web/components/events/EventMemberStack.tsx
new file mode 100644
index 0000000..4f61bfe
--- /dev/null
+++ b/apps/vibely-web/components/events/EventMemberStack.tsx
@@ -0,0 +1,113 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { createClient } from "@/lib/supabase/client";
+
+interface Member {
+ user: {
+ avatar_url: string | null;
+ name: string | null;
+ } | null;
+}
+
+interface EventMemberStackProps {
+ eventId: string;
+}
+
+export default function EventMemberStack({ eventId }: EventMemberStackProps) {
+ const [members, setMembers] = useState
([]);
+ const [totalCount, setTotalCount] = useState(0);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ const fetchMembers = async () => {
+ const supabase = createClient();
+
+ const { data, count, error } = await supabase
+ .from("event_members")
+ .select(
+ `
+ user:users (
+ name,
+ avatar_url
+ )
+ `,
+ { count: "exact" }
+ )
+ .eq("event_id", eventId)
+ .order("joined_at", { ascending: true })
+ .limit(2);
+
+ if (!error && data) {
+ setMembers(data as unknown as Member[]);
+ setTotalCount(count || 0);
+ }
+ setLoading(false);
+ };
+
+ fetchMembers();
+ }, [eventId]);
+
+ const getInitials = (name: string | null) => {
+ if (!name) return "?";
+ return name
+ .split(" ")
+ .map((n) => n[0])
+ .slice(0, 2)
+ .join("")
+ .toUpperCase();
+ };
+
+ if (loading) {
+ return (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ );
+ }
+
+ const avatarsToShow = members.slice(0, 2);
+ const remainingCount = totalCount - avatarsToShow.length;
+
+ return (
+
+ {avatarsToShow.map((member, i) => (
+
+ {member.user?.avatar_url ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : (
+
+ {getInitials(member.user?.name || null)}
+
+ )}
+
+ ))}
+
+ {remainingCount > 0 && (
+
+ +{remainingCount}
+
+ )}
+
+ {totalCount === 0 && (
+
+ 0
+
+ )}
+
+ );
+}
diff --git a/apps/vibely-web/components/events/EventQRPanel.tsx b/apps/vibely-web/components/events/EventQRPanel.tsx
new file mode 100644
index 0000000..dccdf3f
--- /dev/null
+++ b/apps/vibely-web/components/events/EventQRPanel.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import { useState } from "react";
+import { QRCodeCanvas } from "qrcode.react";
+
+interface EventQRPanelProps {
+ inviteToken: string;
+}
+
+export function EventQRPanel({ inviteToken }: EventQRPanelProps) {
+ const [copied, setCopied] = useState(false);
+
+ // Build the full invite URL
+ const inviteUrl =
+ typeof window !== "undefined"
+ ? `${window.location.origin}/join/${inviteToken}`
+ : `/join/${inviteToken}`;
+
+ const copyLink = async () => {
+ try {
+ await navigator.clipboard.writeText(inviteUrl);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch {
+ const input = document.createElement("input");
+ input.value = inviteUrl;
+ document.body.appendChild(input);
+ input.select();
+ document.execCommand("copy");
+ document.body.removeChild(input);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }
+ };
+
+ return (
+
+
+ Scan to Join
+
+
+ Instant photo sharing for your guests.
+
+
+ {/* QR Code Container */}
+
+
+
+
+ {/* Center Logo/Icon on QR */}
+
+
+
+
+ {/* Copy Link Action */}
+
+
+ );
+}
diff --git a/apps/vibely-web/components/events/EventStatsCard.tsx b/apps/vibely-web/components/events/EventStatsCard.tsx
new file mode 100644
index 0000000..946dfcd
--- /dev/null
+++ b/apps/vibely-web/components/events/EventStatsCard.tsx
@@ -0,0 +1,60 @@
+import Image from "next/image";
+import type { EventDetail } from "@/hooks/useEvents";
+
+interface EventStatsCardProps {
+ members: EventDetail["event_members"];
+}
+
+export function EventStatsCard({ members }: EventStatsCardProps) {
+ const totalGuests = members?.length || 0;
+
+ // Show up to 3 avatars
+ const displayMembers = members?.slice(0, 3) || [];
+ const remaining = totalGuests > 3 ? totalGuests - 3 : 0;
+
+ return (
+
+
+
+ Total Guests
+
+
+ {totalGuests}
+
+
+
+
+ {displayMembers.map((member, i) => (
+
+ {member.user?.avatar_url ? (
+
+ ) : (
+
+ {(member.user?.name ?? "?")[0].toUpperCase()}
+
+ )}
+
+ ))}
+
+ {remaining > 0 && (
+
+ +{remaining}
+
+ )}
+
+
+ );
+}
diff --git a/apps/vibely-web/components/events/EventTabs.tsx b/apps/vibely-web/components/events/EventTabs.tsx
new file mode 100644
index 0000000..eab7bed
--- /dev/null
+++ b/apps/vibely-web/components/events/EventTabs.tsx
@@ -0,0 +1,53 @@
+export type EventTabId = "gallery" | "guests" | "settings" | "analytics";
+
+interface EventTabsProps {
+ activeTab: EventTabId;
+ onTabChange: (tab: EventTabId) => void;
+ photoCount: number;
+ guestCount: number;
+}
+
+export function EventTabs({
+ activeTab,
+ onTabChange,
+ photoCount,
+ guestCount,
+}: EventTabsProps) {
+ const tabs: { id: EventTabId; label: string; count?: number }[] = [
+ { id: "gallery", label: "Gallery", count: photoCount },
+ { id: "guests", label: "Guests", count: guestCount },
+ { id: "settings", label: "Settings" },
+ ];
+
+ return (
+
+ );
+}
diff --git a/apps/vibely-web/components/events/LiveActivityFeed.tsx b/apps/vibely-web/components/events/LiveActivityFeed.tsx
new file mode 100644
index 0000000..25a53e8
--- /dev/null
+++ b/apps/vibely-web/components/events/LiveActivityFeed.tsx
@@ -0,0 +1,89 @@
+import Image from "next/image";
+
+// Pre-defined mock activity to populate the feed
+const MOCK_ACTIVITIES = [
+ {
+ id: "act_1",
+ user: {
+ name: "Sarah M.",
+ avatar:
+ "https://lh3.googleusercontent.com/aida-public/AB6AXuBK6tf2CSlaONDq-6yHqo08r8aqYZe2sgtaxFnKkLZkbKjsBQkAJJatIsj1RWrdlDEwixyC0qIp2JDbKazKUak2tRzQgdjrw35NAWHaxz_J1oaaE-c9U0nhmPUv12ubY7JQOf4g-YRV9vENtHtAZoTmJnmmZXo9GPdJJoq45RytBL4mSGGyPrmxHtRRHUf70iBNR0Ki1-ye4IF99411WwngSPt-bqv4jiPddUmwXKFdnGiDI_zgmRb6MJ3DXKHstI_BDGdweqM4maY8",
+ },
+ action: "uploaded 4 photos",
+ timeAgo: "2 minutes ago",
+ },
+ {
+ id: "act_2",
+ user: {
+ name: "David L.",
+ avatar:
+ "https://lh3.googleusercontent.com/aida-public/AB6AXuAmOzJObw64X-YIr4JJsmUT34kvR1frO6mdtGY29pLShh4pB22UOZpWybLInV70tsvHFSdpUAM9scRoBVFrsWb4n1A_dRSLLOI_91f-PfcviKwMhMF1DpAR2rpG0_k0a4eF3SUvxjk7VSy6PuXMeWSTFaS-B1IjN9pQO26kHgC8vyGNroAK0Ml1z9cAYLZ_1vrXE_CxNkIHGjjgxpg-cRsNvjx2JZsXyXhVncEEUSbE30VEF_TS_Qw7GxBjFiOcRHddzT1dx6zLYtLx",
+ },
+ action: "joined the event",
+ timeAgo: "15 minutes ago",
+ },
+ {
+ id: "act_3",
+ user: {
+ name: "Emma W.",
+ avatar:
+ "https://lh3.googleusercontent.com/aida-public/AB6AXuAqSFeWYgQ5I3wT5UswuMifuxHihDsaRkgOX2g_x7W5R2VzbZueX2oa5asEQJWG_HrAffr4oqKH84hGjPWvvtdhxrfLB46wvUK12WJ1Ok-EpC08iS46KgzOw6rtKMoL0xuK0vGEYlGlwfDmbuWy0YyY4uh42rdBJQvk5Lq-1eqfOUr4N8Fzt-9gYiQ5PZIcZyRae9SoCfgEIL4QIQmPAvJk7UZWtC3FuH3jeIYTqSCt7X4OtXP6K9kClTrzHij727gNCuAwHFGGbwOl",
+ },
+ action: "uploaded 8 photos",
+ timeAgo: "1 hour ago",
+ },
+];
+
+export function LiveActivityFeed() {
+ return (
+
+
+
+
+ Live Activity
+
+
+ Happening now
+
+
+
+
+ LIVE
+
+
+
+
+ {MOCK_ACTIVITIES.map((activity) => (
+
+
+
+
+
+
+
+ {activity.user.name}
+ {" "}
+
+ {activity.action}
+
+
+
+ {activity.timeAgo}
+
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/vibely-web/components/landing/BottomCTA.tsx b/apps/vibely-web/components/landing/BottomCTA.tsx
index 6057590..23cf2db 100644
--- a/apps/vibely-web/components/landing/BottomCTA.tsx
+++ b/apps/vibely-web/components/landing/BottomCTA.tsx
@@ -1,28 +1,30 @@
-// ============================================================
-// components/landing/BottomCTA.tsx
-// ============================================================
-// Final call to action section with a large neumorphic card
-// ============================================================
+"use client";
+
import Link from "next/link";
+import { useAuth } from "@/context/AuthContext";
export function BottomCTA() {
+ const { isAuthenticated } = useAuth();
+
return (
-
-
-
-
- Ready to capture the vibe?
-
-
- Join thousands of hosts making their events unforgettable.
-
-
- Start My Free Event
-
-
+
+
+
+
+
+
+ Ready to capture the vibe?
+
+
+ Join thousands of hosts making their events unforgettable. Start your
+ first gallery in 30 seconds.
+
+
+ Create Your First Event — Free
+
);
diff --git a/apps/vibely-web/components/landing/FeaturesSection.tsx b/apps/vibely-web/components/landing/FeaturesSection.tsx
index 73850a0..826a51a 100644
--- a/apps/vibely-web/components/landing/FeaturesSection.tsx
+++ b/apps/vibely-web/components/landing/FeaturesSection.tsx
@@ -1,87 +1,91 @@
-// ============================================================
-// components/landing/FeaturesSection.tsx
-// ============================================================
-// "Features for the perfect host" section.
-// Grid of 6 neumorphic cards displaying app benefits.
-// ============================================================
-import {
- Zap,
- ImageIcon,
- ShieldCheck,
- Play,
- Download,
- Printer,
-} from "lucide-react";
-
-const FEATURES = [
- {
- icon:
,
- title: "Zero App Install",
- description:
- "Guests just scan and snap. No downloads or accounts required for them.",
- },
- {
- icon:
,
- title: "High-Res Original",
- description:
- "No more blurry social media compressed photos. Keep the quality.",
- },
- {
- icon:
,
- title: "Private & Secure",
- description:
- "You control the access. Only your guests can see and upload photos.",
- },
- {
- icon:
,
- title: "Live Slideshow",
- description:
- "Cast a live stream of event photos to any screen during the party.",
- },
- {
- icon:
,
- title: "Bulk Download",
- description:
- "Download all memories with one click at the end of the night.",
- },
- {
- icon:
,
- title: "Physical Prints",
- description:
- "Easily order a professional photo book directly from your gallery.",
- },
-];
-
export function FeaturesSection() {
return (
-
-
-
-
- Features for the perfect host
-
-
+
+
+ {/* Large Feature */}
+
+
+
+ qr_code_2
+
+
+ QR Upload (no app needed)
+
+
+ Guests simply scan a code and start sharing. No downloads, no
+ passwords, no friction. Just instant memories.
+
+
+
+
+
+ add_a_photo
+
+
+
+
+ cloud_upload
+
+
+
+
+ check_circle
+
+
+
-
- {FEATURES.map((feature, index) => (
-
-
- {feature.icon}
-
-
-
- {feature.title}
-
-
- {feature.description}
-
-
-
- ))}
+ {/* Vertical Feature */}
+
+
+ dynamic_feed
+
+
+ Real-time Gallery
+
+
+ Photos appear on the live stream as they happen. Perfect for display
+ on screens during the event.
+
+
+
+
+ {/* Small Features */}
+
+
+ security
+
+
Photo Vault
+
+ Military-grade encryption for all your high-res originals. Permanent
+ and secure storage.
+
+
+
+
+ person_off
+
+
+ Zero Sign-up for Guests
+
+
+ Privacy-first approach. Guests stay anonymous unless they choose to
+ share their name.
+
+
+
+
+ auto_awesome
+
+
+ AI Highlights
+
+
+ Our AI automatically detects the best shots and groups them by
+ moments.
+
diff --git a/apps/vibely-web/components/landing/HeroSection.tsx b/apps/vibely-web/components/landing/HeroSection.tsx
index 20db8e6..051cecd 100644
--- a/apps/vibely-web/components/landing/HeroSection.tsx
+++ b/apps/vibely-web/components/landing/HeroSection.tsx
@@ -1,54 +1,120 @@
"use client";
-// ============================================================
-// components/landing/HeroSection.tsx
-// ============================================================
-// Hero block for the landing page.
-// Headline with violet accent, subtitle, two CTA buttons,
-// and a phone mockup image.
-// ============================================================
-
import Link from "next/link";
-import { PhoneMockup } from "./PhoneMockup";
+import { useAuth } from "@/context/AuthContext";
export function HeroSection() {
+ const { isAuthenticated } = useAuth();
+
return (
-
-
- {/* Text content */}
-
-
- Event photos,{" "}
- without the chase.
-
-
-
- Hosts create an event. Guests scan a QR. Everyone gets the photos
- instantly.
-
-
- {/* CTA buttons */}
-
-
- Create Event
-
-
- See How It Works
-
+
+
+
+
+ New: AI Auto-Curation
+
+
+
+
+ Your Memories,
+
+ Shared Beautifully.
+
+
+
+
+ Vibely is the digital keepsake for your modern events. High-fidelity
+ guest uploads without the friction of app downloads or account creation.
+
+
+
+
+ Create Your First Event — Free
+
+
+ View Demo Gallery
+
+
+
+ {/* Product Mockup Container */}
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

+
+
+
+
+ Summer Gala 2024
+
+
+ 482 photos shared by 86 guests
+
+
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

+
+
+ +84
+
+
+
- {/* Code-based Phone mockup */}
-
-
+ {/* Social Proof mapped into Hero */}
+
+
+ Trusted by event creators globally
+
+
+
+ LUMINA
+
+
+ ETHER
+
+
+ PRISM
+
+
+ VELVET
+
+
+ NOVA
+
-
+
);
}
diff --git a/apps/vibely-web/components/landing/PhoneMockup.tsx b/apps/vibely-web/components/landing/PhoneMockup.tsx
deleted file mode 100644
index ead2895..0000000
--- a/apps/vibely-web/components/landing/PhoneMockup.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-// ============================================================
-// components/landing/PhoneMockup.tsx
-// ============================================================
-// Code-based phone mockup using CSS neumorphism.
-// Replaces the static image.
-// ============================================================
-import { QrCode } from "lucide-react";
-
-export function PhoneMockup() {
- return (
-
- {/* Phone Screen Area */}
-
- {/* Dynamic Island / Notch */}
-
-
- {/* Content Inside Screen */}
-
- {/* Header Card (The QR Code display area) */}
-
-
- {/* Skeletons for UI Elements */}
-
-
- {/* Photo Grid Placeholder */}
-
-
-
-
- );
-}
diff --git a/apps/vibely-web/components/landing/PricingSection.tsx b/apps/vibely-web/components/landing/PricingSection.tsx
new file mode 100644
index 0000000..eb5bf99
--- /dev/null
+++ b/apps/vibely-web/components/landing/PricingSection.tsx
@@ -0,0 +1,146 @@
+export function PricingSection() {
+ return (
+
+
+
+ Transparent Pricing
+
+
+ Choose the perfect plan for your special occasion.
+
+
+
+
+ {/* Free Plan */}
+
+
+ Personal
+
+
Free
+
+ -
+
+ check
+
+ Up to 50 Guests
+
+ -
+
+ check
+
+ 48h Gallery Life
+
+ -
+
+ check
+
+ Standard Resolution
+
+
+
+
+
+ {/* Pro Plan */}
+
+
+ Most Popular
+
+
+ Professional
+
+
+ $49
+
+ /event
+
+
+
+ -
+
+ check
+
+ Unlimited Guests
+
+ -
+
+ check
+
+ Permanent Photo Vault
+
+ -
+
+ check
+
+ Original Resolution Raw Files
+
+ -
+
+ check
+
+ Custom QR Branding
+
+
+
+
+
+ {/* Team Plan */}
+
+
+ Agency
+
+
+ $199
+
+ /mo
+
+
+
+ -
+
+ check
+
+ Up to 10 Concurrent Events
+
+ -
+
+ check
+
+ White-label Platform
+
+ -
+
+ check
+
+ Analytics & Export Tools
+
+ -
+
+ check
+
+ Priority Support
+
+
+
+
+
+
+ );
+}
diff --git a/apps/vibely-web/components/landing/StepsSection.tsx b/apps/vibely-web/components/landing/StepsSection.tsx
deleted file mode 100644
index 0efef43..0000000
--- a/apps/vibely-web/components/landing/StepsSection.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-// ============================================================
-// components/landing/StepsSection.tsx
-// ============================================================
-// "Magic in 3 steps" section with neumorphic cards
-// ============================================================
-import { PlusCircle, QrCode, Image as ImageIcon } from "lucide-react";
-
-const STEPS = [
- {
- icon:
,
- title: "Create Your Event",
- description: "Set up in seconds and get a unique QR code for your gallery.",
- },
- {
- icon:
,
- title: "Share QR Code",
- description:
- "Display the code at your venue. Guests scan to join instantly.",
- },
- {
- icon:
,
- title: "Instant Gallery",
- description: "Every photo taken syncs to one shared space for everyone.",
- },
-];
-
-export function StepsSection() {
- return (
-
-
-
-
- Magic in 3 steps
-
-
- Simple for you, seamless for your guests.
-
-
-
-
- {STEPS.map((step, index) => (
-
- {/* Neumorphic Icon Button container */}
-
- {step.icon}
-
-
- {step.title}
-
-
- {step.description}
-
-
- ))}
-
-
-
- );
-}
diff --git a/apps/vibely-web/components/layout/AppLayout.tsx b/apps/vibely-web/components/layout/AppLayout.tsx
new file mode 100644
index 0000000..9d27023
--- /dev/null
+++ b/apps/vibely-web/components/layout/AppLayout.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { ReactNode, useState } from "react";
+import Sidebar from "@/components/dashboard/Sidebar";
+import MobileNav from "@/components/dashboard/MobileNav";
+
+export default function AppLayout({ children }: { children: ReactNode }) {
+ const [isCollapsed, setIsCollapsed] = useState(false);
+
+ return (
+
+ {/* SideNavBar Component (Desktop) */}
+
+
+ {/* Main Content Area */}
+
+ {children}
+
+
+ {/* BottomNavBar for Mobile */}
+
+
+ );
+}
diff --git a/apps/vibely-web/components/layout/Footer.tsx b/apps/vibely-web/components/layout/Footer.tsx
new file mode 100644
index 0000000..f92385c
--- /dev/null
+++ b/apps/vibely-web/components/layout/Footer.tsx
@@ -0,0 +1,22 @@
+import Link from "next/link";
+
+export default function Footer() {
+ return (
+
+ );
+}
diff --git a/apps/vibely-web/components/layout/NavBar.tsx b/apps/vibely-web/components/layout/NavBar.tsx
deleted file mode 100644
index 5facff5..0000000
--- a/apps/vibely-web/components/layout/NavBar.tsx
+++ /dev/null
@@ -1,186 +0,0 @@
-"use client";
-
-// ============================================================
-// apps/web/components/layout/NavBar.tsx
-// ============================================================
-// Responsive top navigation bar.
-// Desktop: horizontal links
-// Mobile: hamburger menu with slide-down drawer
-// ============================================================
-
-import { useState, useEffect } from "react";
-import Link from "next/link";
-import { usePathname, useRouter } from "next/navigation";
-import { createClient } from "@/lib/supabase/client";
-import Image from "next/image";
-import { MirrorRectangular } from "lucide-react";
-
-const NAV_LINKS = [
- { href: "/dashboard", label: "Events", emoji: "📅" },
- { href: "/vault", label: "Vault", emoji: "🔖" },
- { href: "/profile", label: "Profile", emoji: "👤" },
-];
-
-export function NavBar() {
- const pathname = usePathname();
- const router = useRouter();
- const [menuOpen, setMenuOpen] = useState(false);
- const [userName, setUserName] = useState("");
- const [avatarUrl, setAvatarUrl] = useState
(null);
-
- useEffect(() => {
- const supabase = createClient();
- supabase.auth.getUser().then(({ data: { user } }) => {
- if (!user) return;
- supabase
- .from("users")
- .select("name, avatar_url")
- .eq("id", user.id)
- .single()
- .then(({ data }) => {
- if (data) {
- setUserName(data.name);
- setAvatarUrl(data.avatar_url);
- }
- });
- });
- }, []);
-
- const handleSignOut = async () => {
- const supabase = createClient();
- await supabase.auth.signOut();
- router.push("/login");
- };
-
- // Hide nav on guest and auth pages
- if (
- pathname === "/" ||
- pathname.startsWith("/guest") ||
- pathname.startsWith("/login") ||
- pathname.startsWith("/signup") ||
- pathname.startsWith("/join") ||
- pathname.startsWith("/forgot-password") ||
- pathname.startsWith("/pricing")
- )
- return null;
-
- return (
-
- );
-}
diff --git a/apps/vibely-web/components/layout/NavBarHome.tsx b/apps/vibely-web/components/layout/NavBarHome.tsx
index 42b54ff..87ea81e 100644
--- a/apps/vibely-web/components/layout/NavBarHome.tsx
+++ b/apps/vibely-web/components/layout/NavBarHome.tsx
@@ -1,145 +1,69 @@
"use client";
-// ============================================================
-// components/layout/NavBarHome.tsx
-// ============================================================
-// Landing-page navbar — separate from the dashboard NavBar.
-// Shows: Logo, anchor links (Features / How It Works / Pricing),
-// and a "Create Event" CTA.
-// ============================================================
-
-import { useState } from "react";
import Link from "next/link";
-import { Zap } from "lucide-react";
-import { usePathname, useRouter } from "next/navigation";
-
-const NAV_LINKS = [
- { href: "#features", label: "Features", isScroll: true },
- { href: "#how-it-works", label: "How It Works", isScroll: true },
- { href: "/pricing", label: "Pricing", isScroll: false },
-];
+import { useAuth } from "@/context/AuthContext";
export function NavBarHome() {
- const [menuOpen, setMenuOpen] = useState(false);
- const pathname = usePathname();
- const router = useRouter();
-
- const handleScrollClick = (
- e: React.MouseEvent,
- href: string,
- isScroll: boolean
- ) => {
- if (isScroll) {
- e.preventDefault();
+ const { isAuthenticated } = useAuth();
- if (pathname !== "/") {
- router.push("/" + href);
- setMenuOpen(false);
- return;
- }
-
- const targetId = href.replace("#", "");
- const elem = document.getElementById(targetId);
- if (elem) {
- elem.scrollIntoView({ behavior: "smooth" });
- }
- setMenuOpen(false);
- } else {
- setMenuOpen(false);
+ const handlePricingClick = (e: React.MouseEvent) => {
+ e.preventDefault();
+ const pricingSection = document.getElementById("pricing");
+ if (pricingSection) {
+ pricingSection.scrollIntoView({ behavior: "smooth" });
}
};
return (
-
-