diff --git a/frontend/app/(auth)/admin/gallery/[id]/page.tsx b/frontend/app/(auth)/admin/gallery/[id]/page.tsx new file mode 100644 index 0000000..53261d7 --- /dev/null +++ b/frontend/app/(auth)/admin/gallery/[id]/page.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { useParams } from "next/navigation"; +import GalleryFormWrapper from "@/components/admin/gallery/GalleryFormWrapper"; +import { RoleProtectedPage } from "@/components/admin/RoleProtectedPage"; +import { AdminRole } from "@/lib/types/user"; + +export default function EditGalleryImagePage() { + const params = useParams(); + const id = params.id as string; + + return ( + + + + ); +} diff --git a/frontend/app/(auth)/admin/gallery/new/page.tsx b/frontend/app/(auth)/admin/gallery/new/page.tsx new file mode 100644 index 0000000..e173465 --- /dev/null +++ b/frontend/app/(auth)/admin/gallery/new/page.tsx @@ -0,0 +1,16 @@ +import { Metadata } from "next"; +import GalleryFormWrapper from "@/components/admin/gallery/GalleryFormWrapper"; +import { RoleProtectedPage } from "@/components/admin/RoleProtectedPage"; +import { AdminRole } from "@/lib/types/user"; + +export const metadata: Metadata = { + title: "Add Gallery Image", +}; + +export default function NewGalleryImagePage() { + return ( + + + + ); +} diff --git a/frontend/app/(auth)/admin/instagram/InstagramClient.tsx b/frontend/app/(auth)/admin/instagram/InstagramClient.tsx new file mode 100644 index 0000000..b3513fd --- /dev/null +++ b/frontend/app/(auth)/admin/instagram/InstagramClient.tsx @@ -0,0 +1,246 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { Plus, Instagram as InstagramIcon, Eye, EyeOff, Loader2 } from "lucide-react"; +import { motion } from "framer-motion"; +import toast from "react-hot-toast"; +import { adminApi } from "@/lib/api/admin"; +import { InstagramTable } from "@/components/admin/instagram/InstagramTable"; +import { Button } from "@/components/ui/Button"; +import type { InstagramPost } from "@/lib/api/admin"; + +export default function InstagramClient() { + const router = useRouter(); + const queryClient = useQueryClient(); + + // Fetch Instagram posts + const { + data: posts = [], + isLoading, + error, + } = useQuery({ + queryKey: ["instagram-posts"], + queryFn: () => adminApi.getAllInstagramPosts(), + }); + + // Toggle visibility mutation + const toggleVisibilityMutation = useMutation({ + mutationFn: adminApi.toggleInstagramPostVisibility, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["instagram-posts"] }); + toast.success("Post visibility updated successfully"); + }, + onError: (error: any) => { + toast.error(error.message || "Failed to update post visibility"); + }, + }); + + // Delete mutation + const deleteMutation = useMutation({ + mutationFn: adminApi.deleteInstagramPost, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["instagram-posts"] }); + toast.success("Instagram post deleted successfully"); + }, + onError: (error: any) => { + toast.error(error.message || "Failed to delete post"); + }, + }); + + // Calculate stats + const stats = { + total: posts.length, + visible: posts.filter((post: InstagramPost) => post.isVisible).length, + hidden: posts.filter((post: InstagramPost) => !post.isVisible).length, + }; + + const handleToggleVisibility = (id: string) => { + toggleVisibilityMutation.mutate(id); + }; + + const handleDelete = async (id: string) => { + await deleteMutation.mutateAsync(id); + }; + + // Loading skeleton + if (isLoading) { + return ( +
+
+
+

Instagram Posts

+

Manage your Instagram feed

+
+
+
+ + {/* Stats skeletons */} +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+ ))} +
+ + {/* Table skeleton */} +
+
+ {[1, 2, 3, 4, 5].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+
+
+ ); + } + + // Error state + if (error) { + return ( +
+
+
+ +
+

+ Failed to load Instagram posts +

+

+ Please check your connection and try again. +

+ +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Instagram Posts

+

Manage your Instagram feed

+
+ +
+ + {/* Stats Cards */} +
+ +
+
+
+ +
+
+
+

Total Posts

+

{stats.total}

+
+
+
+ + +
+
+
+ +
+
+
+

Visible

+

{stats.visible}

+
+
+
+ + +
+
+
+ +
+
+
+

Hidden

+

{stats.hidden}

+
+
+
+
+ + {/* Table */} + + + + + {/* Empty state */} + {posts.length === 0 && ( + +
+ +
+

+ No Instagram posts yet +

+

+ Get started by adding your first Instagram post to the feed. +

+ +
+ )} +
+ ); +} diff --git a/frontend/app/(auth)/admin/instagram/[id]/page.tsx b/frontend/app/(auth)/admin/instagram/[id]/page.tsx new file mode 100644 index 0000000..2c18eb3 --- /dev/null +++ b/frontend/app/(auth)/admin/instagram/[id]/page.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { useParams } from "next/navigation"; +import InstagramFormWrapper from "../../../../components/admin/instagram/InstagramFormWrapper"; +import { RoleProtectedPage } from "../../../../components/admin/RoleProtectedPage"; +import { AdminRole } from "@/lib/types/user"; + +export default function EditInstagramPostPage() { + const params = useParams(); + const id = params.id as string; + + return ( + + + + ); +} diff --git a/frontend/app/(auth)/admin/instagram/new/page.tsx b/frontend/app/(auth)/admin/instagram/new/page.tsx new file mode 100644 index 0000000..339a2e0 --- /dev/null +++ b/frontend/app/(auth)/admin/instagram/new/page.tsx @@ -0,0 +1,16 @@ +import { Metadata } from "next"; +import InstagramFormWrapper from "../../../../components/admin/instagram/InstagramFormWrapper"; +import { RoleProtectedPage } from "../../../../components/admin/RoleProtectedPage"; +import { AdminRole } from "@/lib/types/user"; + +export const metadata: Metadata = { + title: "Add Instagram Post", +}; + +export default function NewInstagramPostPage() { + return ( + + + + ); +} diff --git a/frontend/app/(auth)/admin/instagram/page.tsx b/frontend/app/(auth)/admin/instagram/page.tsx new file mode 100644 index 0000000..da65278 --- /dev/null +++ b/frontend/app/(auth)/admin/instagram/page.tsx @@ -0,0 +1,16 @@ +import { Metadata } from "next"; +import InstagramClient from "./InstagramClient"; +import { RoleProtectedPage } from "@/components/admin/RoleProtectedPage"; +import { AdminRole } from "@/lib/types/user"; + +export const metadata: Metadata = { + title: "Instagram", +}; + +export default function InstagramManagementPage() { + return ( + + + + ); +} diff --git a/frontend/app/(auth)/login/LoginClient.tsx b/frontend/app/(auth)/login/LoginClient.tsx new file mode 100644 index 0000000..d068215 --- /dev/null +++ b/frontend/app/(auth)/login/LoginClient.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Eye, EyeOff, Loader2 } from "lucide-react"; +import { adminApi } from "@/lib/api/api"; +import { useAuthStore } from "@/lib/store/authStore"; +import Link from "next/link"; + +const loginSchema = z.object({ + username: z.string().min(1, "Username is required"), + password: z.string().min(1, "Password is required"), +}); + +type LoginFormData = z.infer; + +export default function LoginClient() { + const router = useRouter(); + const { isAuthenticated, setAuth } = useAuthStore(); + const [showPassword, setShowPassword] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(loginSchema), + }); + + useEffect(() => { + if (isAuthenticated) { + router.push("/admin/dashboard"); + } + }, [isAuthenticated, router]); + + const onSubmit = async (data: LoginFormData) => { + setIsLoading(true); + setError(null); + + try { + const response = await adminApi.login(data); + setAuth(response.user, response.token); + router.push("/admin/dashboard"); + } catch (err) { + setError(err instanceof Error ? err.message : "Login failed"); + } finally { + setIsLoading(false); + } + }; + + const allowRegistration = process.env.NEXT_PUBLIC_ALLOW_REGISTRATION === "true"; + + return ( +
+
+
+
+
+
+

+ Admin Login +

+

+ Sign in to your admin account +

+
+ +
+
+
+ + + {errors.username && ( +

+ {errors.username.message} +

+ )} +
+
+ + + + {errors.password && ( +

+ {errors.password.message} +

+ )} +
+
+ + {error && ( +
+

{error}

+
+ )} + +
+ +
+ + {allowRegistration && ( +
+

+ Don't have an account?{" "} + + Register + +

+
+ )} +
+
+
+ ); +} diff --git a/frontend/app/(auth)/login/page.tsx b/frontend/app/(auth)/login/page.tsx new file mode 100644 index 0000000..5b56c65 --- /dev/null +++ b/frontend/app/(auth)/login/page.tsx @@ -0,0 +1,10 @@ +import { Metadata } from "next"; +import LoginClient from "./LoginClient"; + +export const metadata: Metadata = { + title: "Admin Login", +}; + +export default function LoginPage() { + return ; +} diff --git a/frontend/app/components/admin/gallery/GalleryForm.tsx b/frontend/app/components/admin/gallery/GalleryForm.tsx new file mode 100644 index 0000000..d169084 --- /dev/null +++ b/frontend/app/components/admin/gallery/GalleryForm.tsx @@ -0,0 +1,205 @@ +"use client"; + +import React from "react"; +import { useForm, Controller } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Button } from "../../ui/Button"; +import { Input } from "../../ui/Input"; +import { ImageUpload } from "../../ui/ImageUpload"; +import { cn } from "@/lib/utils"; +import type { GalleryCategory } from "@/lib/api/admin"; + +const galleryImageSchema = z.object({ + imageUrl: z.string().min(1, "Image is required"), + alt: z.string().min(1, "Alt text is required"), + category: z.enum(["food", "ambiance", "kitchen", "events", "drinks"]), + isVisible: z.boolean().optional(), + displayOrder: z.number().min(0).optional(), +}); + +export type GalleryFormData = z.infer; + +interface GalleryFormProps { + initialData?: Partial; + onSubmit: (data: GalleryFormData) => Promise; + isSubmitting: boolean; + submitLabel?: string; +} + +const categories: { value: GalleryCategory; label: string }[] = [ + { value: "food", label: "Food" }, + { value: "ambiance", label: "Ambiance" }, + { value: "kitchen", label: "Kitchen" }, + { value: "events", label: "Events" }, + { value: "drinks", label: "Drinks" }, +]; + +export function GalleryForm({ + initialData, + onSubmit, + isSubmitting, + submitLabel = "Save Image", +}: GalleryFormProps) { + const { + register, + handleSubmit, + control, + formState: { errors }, + } = useForm({ + resolver: zodResolver(galleryImageSchema), + defaultValues: { + isVisible: true, + displayOrder: 0, + imageUrl: "", + alt: "", + category: "food", + ...initialData, + }, + }); + + return ( +
+ {/* Image Upload */} +
+

Gallery Image

+ +
+
+ + ( + + )} + /> +

+ Upload a high-quality image. Recommended: 1920x1080px (16:9 ratio) + for gallery display. +

+
+
+
+ + {/* Image Details */} +
+

+ Image Details +

+ +
+ {/* Alt Text */} +
+ + +

+ This description helps with SEO and accessibility for visually impaired users. +

+
+ + {/* Category */} +
+ + + {errors.category?.message && ( +

+ {errors.category.message} +

+ )} +

+ Select the category that best describes this image. +

+
+ + {/* Display Order */} +
+ + +

+ Lower numbers appear first. Use this to manually order your gallery images. +

+
+ + {/* Visibility Toggle */} +
+ +
+
+
+ + {/* Submit Button */} +
+ +
+
+ ); +} diff --git a/frontend/app/components/admin/gallery/GalleryFormWrapper.tsx b/frontend/app/components/admin/gallery/GalleryFormWrapper.tsx new file mode 100644 index 0000000..011d58d --- /dev/null +++ b/frontend/app/components/admin/gallery/GalleryFormWrapper.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { GalleryForm, GalleryFormData } from "./GalleryForm"; +import { adminApi } from "@/lib/api/admin"; +import type { GalleryImage } from "@/lib/api/admin"; + +interface GalleryFormWrapperProps { + mode: "create" | "edit"; + initialData?: Partial; + imageId?: string; +} + +export default function GalleryFormWrapper({ + mode, + initialData, + imageId +}: GalleryFormWrapperProps) { + const router = useRouter(); + const [isSubmitting, setIsSubmitting] = useState(false); + + // For edit mode, fetch the image data + const { data: imageResponse, isLoading, error } = useQuery({ + queryKey: ["gallery-image", imageId], + queryFn: () => adminApi.getGalleryImage(imageId!), + enabled: mode === "edit" && !!imageId, + }); + + const image = imageResponse; + + const createMutation = useMutation({ + mutationFn: adminApi.createGalleryImage, + onSuccess: () => { + router.push("/admin/gallery"); + }, + onError: (error) => { + console.error("Failed to create gallery image:", error); + }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: Partial }) => + adminApi.updateGalleryImage(id, data), + onSuccess: () => { + router.push("/admin/gallery"); + }, + onError: (error) => { + console.error("Failed to update gallery image:", error); + }, + }); + + const handleSubmit = async (data: GalleryFormData) => { + setIsSubmitting(true); + try { + if (mode === "create") { + await createMutation.mutateAsync(data); + } else if (mode === "edit" && imageId) { + await updateMutation.mutateAsync({ id: imageId, data }); + } + } finally { + setIsSubmitting(false); + } + }; + + if (mode === "edit" && isLoading) { + return ( +
+
+
+

Loading gallery image...

+
+
+ ); + } + + if (mode === "edit" && error) { + return ( +
+
+

+ Failed to load gallery image. Please try again. +

+
+
+ ); + } + + const submitLabel = mode === "create" ? "Add to Gallery" : "Update Image"; + const formData = mode === "edit" && image ? image : initialData; + + return ( +
+
+

+ {mode === "create" ? "Add to Gallery" : "Update Image"} +

+

+ {mode === "create" + ? "Add a new image to your gallery collection." + : "Update the gallery image details." + } +

+
+ + +
+ ); +} diff --git a/frontend/app/components/admin/instagram/InstagramFormWrapper.tsx b/frontend/app/components/admin/instagram/InstagramFormWrapper.tsx new file mode 100644 index 0000000..8324131 --- /dev/null +++ b/frontend/app/components/admin/instagram/InstagramFormWrapper.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { InstagramForm, InstagramFormData } from "./InstagramForm"; +import { instagramApi } from "@/lib/api/api"; +import { InstagramPost } from "@/lib/types"; + +interface InstagramFormWrapperProps { + mode: "create" | "edit"; + initialData?: Partial; + postId?: string; +} + +export default function InstagramFormWrapper({ + mode, + initialData, + postId +}: InstagramFormWrapperProps) { + const router = useRouter(); + const [isSubmitting, setIsSubmitting] = useState(false); + + // For edit mode, fetch the post data + const { data: postResponse, isLoading, error } = useQuery({ + queryKey: ["instagram-post", postId], + queryFn: () => instagramApi.getById(postId!), + enabled: mode === "edit" && !!postId, + }); + + const post = postResponse?.data; + + const createMutation = useMutation({ + mutationFn: (data: InstagramFormData) => instagramApi.create(data), + onSuccess: () => { + router.push("/admin/instagram"); + }, + onError: (error) => { + console.error("Failed to create Instagram post:", error); + }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: Partial }) => + instagramApi.update(id, data), + onSuccess: () => { + router.push("/admin/instagram"); + }, + onError: (error) => { + console.error("Failed to update Instagram post:", error); + }, + }); + + const handleSubmit = async (data: InstagramFormData) => { + setIsSubmitting(true); + try { + if (mode === "create") { + await createMutation.mutateAsync(data); + } else if (mode === "edit" && postId) { + await updateMutation.mutateAsync({ id: postId, data }); + } + } finally { + setIsSubmitting(false); + } + }; + + if (mode === "edit" && isLoading) { + return ( +
+
+
+

Loading Instagram post...

+
+
+ ); + } + + if (mode === "edit" && error) { + return ( +
+
+

+ Failed to load Instagram post. Please try again. +

+
+
+ ); + } + + const submitLabel = mode === "create" ? "Create Post" : "Update Post"; + const formData = mode === "edit" && post ? post : initialData; + + return ( +
+
+

+ {mode === "create" ? "Add Instagram Post" : "Edit Instagram Post"} +

+

+ {mode === "create" + ? "Add a new Instagram post to display on your website." + : "Update the Instagram post details." + } +

+
+ + +
+ ); +} diff --git a/frontend/lib/api/api.ts b/frontend/lib/api/api.ts index c771196..986b4f6 100644 --- a/frontend/lib/api/api.ts +++ b/frontend/lib/api/api.ts @@ -6,6 +6,7 @@ import type { InstagramPost, } from "@/lib/types"; import { GalleryImage } from "./admin"; +import { AdminUser } from "@/lib/types/user"; const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:9001/api"; const RESTAURANT_ID = process.env.NEXT_PUBLIC_RESTAURANT_ID; @@ -114,6 +115,43 @@ export const contactApi = { export const instagramApi = { getPosts: (limit: number = 9) => fetchApi(addRestaurantId(`/instagram?limit=${limit}`)), + + getById: (id: string) => + fetchApi(addRestaurantId(`/instagram/${id}`)), + + create: async (data: Omit) => { + const response = await fetch(`${API_URL}${addRestaurantId('/instagram')}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || 'Failed to create Instagram post'); + } + + return response.json(); + }, + + update: async (id: string, data: Partial) => { + const response = await fetch(`${API_URL}${addRestaurantId(`/instagram/${id}`)}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || 'Failed to update Instagram post'); + } + + return response.json(); + }, }; // Gallery API @@ -149,6 +187,30 @@ export const qrApi = { }), }; +// Admin API +export const adminApi = { + login: async (credentials: { username: string; password: string }) => { + const response = await fetch(`${API_URL}/admin/login`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(credentials), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || "Login failed"); + } + + const data = await response.json(); + return { + token: data.token, + user: data.user as AdminUser, + }; + }, +}; + const apiClient = { menu: menuApi, trending: trendingApi, @@ -157,6 +219,7 @@ const apiClient = { gallery: galleryApi, analytics: analyticsApi, qr: qrApi, + admin: adminApi, }; export default apiClient;