commit b1a44f915d279c827a3f1c2811db9aa86c05924a Author: Ami Date: Sat Apr 11 01:01:25 2026 +0700 feat: portal-mini-store-template with PDPA consent logging - Payload CMS 3.49.1 + MongoDB - ConsentLogs collection for PDPA compliance - CookieBanner component with Accept/Reject/Preferences - /api/consent endpoint for logging - Cart, checkout, orders functionality - Docker compose with MongoDB diff --git a/src/.env.example b/src/.env.example new file mode 100644 index 0000000..ae261d5 --- /dev/null +++ b/src/.env.example @@ -0,0 +1,13 @@ +# Payload CMS +PAYLOAD_SECRET=your-secret-key-here-change-in-production + +# MongoDB (used by docker-compose) +MONGODB_URL=mongodb://mongo:27017/portal-mini-store + +# Server URL (for CORS and API) +NEXT_PUBLIC_SERVER_URL=http://localhost:3000 + +# Email (optional - for password reset) +# GMAIL_USER=your-email@gmail.com +# GOOGLE_APP_PASSWORD=your-app-password +# EMAIL_DEFAULT_FROM_NAME=Portal Mini Store diff --git a/src/.gitignore b/src/.gitignore new file mode 100644 index 0000000..70cd106 --- /dev/null +++ b/src/.gitignore @@ -0,0 +1,50 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +/.idea/* +!/.idea/runConfigurations + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +.env + +/media +.pnpm-store/ +node_modules/ +.next/ +dist/ +.pnpm-store/ +node_modules/ +.next/ diff --git a/src/Dockerfile b/src/Dockerfile new file mode 100644 index 0000000..93465cf --- /dev/null +++ b/src/Dockerfile @@ -0,0 +1,71 @@ +# To use this Dockerfile, you have to set `output: 'standalone'` in your next.config.mjs file. +# From https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile + +FROM node:22.12.0-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Install dependencies based on the preferred package manager +COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./ +RUN \ + if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ + elif [ -f package-lock.json ]; then npm ci; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ + else echo "Lockfile not found." && exit 1; \ + fi + + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Next.js collects completely anonymous telemetry data about general usage. +# Learn more here: https://nextjs.org/telemetry +# Uncomment the following line in case you want to disable telemetry during the build. +# ENV NEXT_TELEMETRY_DISABLED 1 + +RUN \ + if [ -f yarn.lock ]; then yarn run build; \ + elif [ -f package-lock.json ]; then npm run build; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \ + else echo "Lockfile not found." && exit 1; \ + fi + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV production +# Uncomment the following line in case you want to disable telemetry during runtime. +# ENV NEXT_TELEMETRY_DISABLED 1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +# Remove this line if you do not have this folder +COPY --from=builder /app/public ./public + +# Set the correct permission for prerender cache +RUN mkdir .next +RUN chown nextjs:nodejs .next + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT 3000 + +# server.js is created by next build from the standalone output +# https://nextjs.org/docs/pages/api-reference/next-config-js/output +CMD HOSTNAME="0.0.0.0" node server.js diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..9dfc193 --- /dev/null +++ b/src/README.md @@ -0,0 +1,202 @@ +# 🍿 Dyad Snacks + +This template comes configured with the bare minimum to get started on anything you need. + +## Features + +### 🌟 Public Features (No Authentication Required) +- **Browse Snacks**: View all available snack items with images, descriptions, prices, and categories +- **Responsive Design**: Fully responsive interface that works on desktop, tablet, and mobile devices + +### 👤 Authenticated User Features +- **User Registration & Login**: Secure authentication system +- **Place Orders**: Add snacks to cart and place orders +- **Order History**: View personal order history with status tracking +- **Order Tracking**: See order status (Pending, Completed, Cancelled) + +### 🔧 Admin Features +- **Admin Dashboard**: Comprehensive order management interface +- **Order Management**: Review all orders from all customers +- **Status Updates**: Update order status (Pending → Completed/Cancelled) +- **Order Statistics**: View summary statistics of all orders +- **Snack Management**: Add, edit, and manage snack inventory through Payload CMS admin panel + +## Tech Stack + +- **Frontend**: Next.js 15, React 19, TypeScript +- **Backend**: Payload CMS 3.0 +- **Database**: Vercel Postgres +- **Authentication**: Built-in Payload authentication with role-based access +- **Styling**: Custom CSS with modern responsive design +- **Media**: Sharp for image processing + +## User Roles + +### Regular Users (`role: 'user'`) +- Can view all available snacks +- Can place orders for snacks +- Can view their own order history +- Cannot modify or cancel orders once placed + +### Admin Users (`role: 'admin'`) +- All regular user permissions +- Can access admin dashboard +- Can view all orders from all customers +- Can update order status +- Can manage snack inventory through CMS admin panel + +## Collections + +### Users +- Email, first name, last name +- Role-based authentication (user/admin) +- Default role: 'user' + +### Snacks +- Name, description, price, category +- Image upload with media relation +- Availability toggle +- Categories: Chips, Candy, Cookies, Nuts, Crackers, Drinks + +### Orders +- User relationship +- Array of items (snack + quantity) +- Total amount calculation +- Status tracking (pending/completed/cancelled) +- Order date tracking + +### Media +- Image upload and management +- Alt text for accessibility + +## Getting Started + +### Prerequisites +- Node.js 18+ or 20+ +- pnpm 9+ or 10+ +- PostgreSQL database (Vercel Postgres recommended) + +### Installation + +1. **Clone the repository** + ```bash + git clone + cd dyad-snacks + ``` + +2. **Install dependencies** + ```bash + pnpm install + ``` + +3. **Environment Setup** + ```bash + cp .env.example .env + ``` + + Configure your environment variables: + ```env + PAYLOAD_SECRET=your-secret-key + POSTGRES_URL=your-postgres-connection-string + ``` + +4. **Start the development server** + ```bash + pnpm dev + ``` + +5. **Open your browser** + Navigate to `http://localhost:3000` + +### First Time Setup + +1. **Create Admin User**: Visit `/admin` to create your first admin user +2. **Add Snacks**: Use the admin panel to add snack items with images +3. **Test Ordering**: Create a regular user account to test the ordering flow + +## API Endpoints + +### Orders +- `POST /api/orders` - Create a new order (authenticated users) +- `PATCH /api/orders/update-status` - Update order status (admin only) + +### Built-in Payload Endpoints +- `/api/users` - User management +- `/api/snacks` - Snack management +- `/api/media` - Media upload/management +- `/admin` - Admin panel access + +## Application Flow + +### For Visitors (Unauthenticated) +1. **Homepage**: Browse all available snacks +2. **Login Required**: Click "Login to Order" to authenticate +3. **Registration**: Create account with first name, last name, email, password + +### For Regular Users +1. **Browse & Order**: View snacks and click "Order Now" +2. **Order Form**: Select quantity and place order +3. **Order Confirmation**: Redirected to "My Orders" with success message +4. **Order History**: View all personal orders with status + +### For Admin Users +1. **Admin Dashboard**: Access via navigation or direct link +2. **Order Overview**: See statistics and all orders +3. **Status Management**: Update order status with real-time buttons +4. **Inventory Management**: Access full CMS admin panel + +## Responsive Design + +The application is fully responsive with breakpoints: +- **Desktop**: 1200px+ (full grid layout, side-by-side forms) +- **Tablet**: 768px-1199px (adapted grid, stacked layouts) +- **Mobile**: <768px (single column, touch-friendly buttons) + +## Security Features + +- **Role-based Access Control**: Proper separation of user and admin permissions +- **Authentication Required**: Protected routes for ordering and admin functions +- **Data Validation**: Server-side validation for all order data +- **Price Verification**: Server validates prices to prevent manipulation + +## Deployment + +### Using Payload Cloud +1. Connect your repository to Payload Cloud +2. Configure environment variables +3. Deploy automatically with MongoDB and S3 storage + +### Using Vercel +1. Connect repository to Vercel +2. Configure Vercel Postgres database +3. Set environment variables +4. Deploy + +## Development Commands + +```bash +pnpm dev # Start development server +pnpm build # Build for production +pnpm start # Start production server +pnpm generate:types # Generate TypeScript types +pnpm lint # Run ESLint +``` + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Test thoroughly +5. Submit a pull request + +## Support + +For questions or issues: +- Check the [Payload CMS documentation](https://payloadcms.com/docs) +- Review the Next.js documentation +- Open an issue in the repository + +--- + +**Built with ❤️ using Payload CMS and Next.js** diff --git a/src/app/(frontend)/admin-dashboard/order-status-update.tsx b/src/app/(frontend)/admin-dashboard/order-status-update.tsx new file mode 100644 index 0000000..d4f7e10 --- /dev/null +++ b/src/app/(frontend)/admin-dashboard/order-status-update.tsx @@ -0,0 +1,87 @@ +'use client' + +import React, { useState } from 'react' +import { useRouter } from 'next/navigation' +import { Button } from '@/components/ui/button' +import { Alert, AlertDescription } from '@/components/ui/alert' + +interface OrderStatusUpdateProps { + orderId: string + currentStatus: string +} + +export default function OrderStatusUpdate({ orderId, currentStatus }: OrderStatusUpdateProps) { + const [status, setStatus] = useState(currentStatus) + const [isUpdating, setIsUpdating] = useState(false) + const [error, setError] = useState('') + const router = useRouter() + + const handleStatusUpdate = async (newStatus: string) => { + if (newStatus === status) return + + setIsUpdating(true) + setError('') + + try { + const response = await fetch('/api/orders/update-status', { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + orderId, + status: newStatus, + }), + }) + + if (response.ok) { + setStatus(newStatus) + router.refresh() // Refresh the page to show updated data + } else { + const errorData = await response.json() + setError(errorData.message || 'Failed to update status') + } + } catch (err) { + setError('Failed to update status. Please try again.') + } finally { + setIsUpdating(false) + } + } + + return ( +
+
+ + + +
+ {error && ( + + {error} + + )} + {isUpdating &&
Updating...
} +
+ ) +} diff --git a/src/app/(frontend)/admin-dashboard/page.tsx b/src/app/(frontend)/admin-dashboard/page.tsx new file mode 100644 index 0000000..c6b9bf7 --- /dev/null +++ b/src/app/(frontend)/admin-dashboard/page.tsx @@ -0,0 +1,189 @@ +import { headers as getHeaders } from 'next/headers.js' +import Image from 'next/image' +import { getPayload } from 'payload' +import React from 'react' +import Link from 'next/link' +import { redirect } from 'next/navigation' + +import config from '@/payload.config' +import OrderStatusUpdate from './order-status-update' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Separator } from '@/components/ui/separator' + +export default async function AdminDashboardPage() { + const headers = await getHeaders() + const payloadConfig = await config + const payload = await getPayload({ config: payloadConfig }) + const { user } = await payload.auth({ headers }) + + // Redirect to login if not authenticated or not admin + if (!user || (user as any).role !== 'admin') { + redirect('/') + } + + // Fetch all orders + const orders = await payload.find({ + collection: 'orders', + depth: 3, + sort: '-orderDate', + limit: 50, + }) + + // Get order statistics + const pendingOrders = orders.docs.filter((order: any) => order.status === 'pending') + const completedOrders = orders.docs.filter((order: any) => order.status === 'completed') + const cancelledOrders = orders.docs.filter((order: any) => order.status === 'cancelled') + + return ( +
+
+
+ +

Admin Dashboard

+
+ + {/* Statistics Grid */} +
+ + + Pending Orders + + +
{pendingOrders.length}
+
+
+ + + Completed Orders + + +
{completedOrders.length}
+
+
+ + + Cancelled Orders + + +
{cancelledOrders.length}
+
+
+ + + Total Orders + + +
{orders.docs.length}
+
+
+
+ + + + {/* Orders Section */} +
+

All Orders

+ + {orders.docs.length === 0 ? ( + + +

No orders found.

+
+
+ ) : ( +
+ {orders.docs.map((order: any) => ( + + +
+
+ + Order #{String(order.id).slice(-8)} + + + Customer: {order.user?.firstName} {order.user?.lastName} ( + {order.user?.email}) + +

+ Ordered:{' '} + {new Date(order.orderDate).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + })} +

+
+
+ + {order.status.charAt(0).toUpperCase() + order.status.slice(1)} + + +
+
+
+ + +
+ {order.items.map((item: any, index: number) => ( +
+ {item.snack && + typeof item.snack === 'object' && + item.snack.image && + typeof item.snack.image === 'object' && + item.snack.image.url && ( +
+ {item.snack.image.alt +
+ )} +
+

{item.snack?.name || 'Unknown Item'}

+

+ Qty: {item.quantity} × ${item.snack?.price?.toFixed(2) || '0.00'} +

+
+
+ ${((item.snack?.price || 0) * item.quantity).toFixed(2)} +
+
+ ))} +
+ + + +
+ + Total: ${order.totalAmount.toFixed(2)} + +
+
+
+ ))} +
+ )} +
+
+
+ ) +} diff --git a/src/app/(frontend)/checkout/checkout-form.tsx b/src/app/(frontend)/checkout/checkout-form.tsx new file mode 100644 index 0000000..f500d79 --- /dev/null +++ b/src/app/(frontend)/checkout/checkout-form.tsx @@ -0,0 +1,144 @@ +'use client' + +import React, { useState } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +import Image from 'next/image' + +import { useCart } from '@/lib/cart-context' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Separator } from '@/components/ui/separator' +import { Alert, AlertDescription } from '@/components/ui/alert' + +interface CheckoutFormProps { + user: any +} + +export const CheckoutForm: React.FC = ({ user }) => { + const { state, clearCart, getTotalPrice } = useCart() + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState(null) + const router = useRouter() + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (state.items.length === 0) { + setError('Your cart is empty') + return + } + + setIsSubmitting(true) + setError(null) + + try { + const response = await fetch('/api/orders', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + items: state.items.map((item) => ({ + snack: item.id, + quantity: item.quantity, + })), + totalAmount: getTotalPrice(), + }), + }) + + if (!response.ok) { + throw new Error('Failed to place order') + } + + const result = await response.json() + clearCart() + router.push(`/my-orders?success=true&orderId=${result.doc.id}`) + } catch (err) { + setError('Failed to place order. Please try again.') + console.error('Order submission error:', err) + } finally { + setIsSubmitting(false) + } + } + + if (state.items.length === 0) { + return ( +
+

Your cart is empty

+ +
+ ) + } + + return ( +
+ {/* Order Summary */} +
+

Order Summary

+
+ {state.items.map((item) => ( + +
+ {item.image && ( +
+ {item.image.alt +
+ )} +
+

{item.name}

+ + {item.category} + +

+ ${item.price.toFixed(2)} × {item.quantity} +

+
+
+

${(item.price * item.quantity).toFixed(2)}

+
+
+
+ ))} +
+
+ + + + {/* Total */} +
+ Total: + ${getTotalPrice().toFixed(2)} +
+ + {/* Error Message */} + {error && ( + + {error} + + )} + + {/* Submit Button */} +
+ +
+ +
+

+ Order will be placed for: {user.firstName} {user.lastName} +

+

Email: {user.email}

+
+ + ) +} diff --git a/src/app/(frontend)/checkout/page.tsx b/src/app/(frontend)/checkout/page.tsx new file mode 100644 index 0000000..e88e0a2 --- /dev/null +++ b/src/app/(frontend)/checkout/page.tsx @@ -0,0 +1,43 @@ +import { headers as getHeaders } from 'next/headers.js' +import { getPayload } from 'payload' +import React from 'react' +import { redirect } from 'next/navigation' + +import config from '@/payload.config' +import { CheckoutForm } from './checkout-form' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import Link from 'next/link' + +export default async function CheckoutPage() { + const headers = await getHeaders() + const payloadConfig = await config + const payload = await getPayload({ config: payloadConfig }) + const { user } = await payload.auth({ headers }) + + // Redirect to login if not authenticated + if (!user) { + redirect('/login') + } + + return ( +
+
+ + +
+ + + Checkout + + + + + +
+
+
+ ) +} diff --git a/src/app/(frontend)/forgot-password/page.tsx b/src/app/(frontend)/forgot-password/page.tsx new file mode 100644 index 0000000..305d369 --- /dev/null +++ b/src/app/(frontend)/forgot-password/page.tsx @@ -0,0 +1,154 @@ +'use client' + +import React, { useState } from 'react' +import Link from 'next/link' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { SiteHeader } from '@/components/site-header' + +export default function ForgotPasswordPage() { + const [email, setEmail] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState('') + const [success, setSuccess] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setIsSubmitting(true) + setError('') + + if (!email) { + setError('Email address is required') + setIsSubmitting(false) + return + } + + try { + const response = await fetch('/api/users/forgot-password', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email: email, + }), + }) + + if (response.ok) { + setSuccess(true) + } else { + const errorData = await response.json() + setError(errorData.message || 'Failed to send reset email. Please try again.') + } + } catch (err) { + setError('Failed to send reset email. Please try again.') + } finally { + setIsSubmitting(false) + } + } + + if (success) { + return ( +
+
+ + + {/* Success Message */} + + + Reset link sent + + If an account with that email exists, we've sent a password reset link to{' '} + {email} + + + + + + Please check your email and follow the instructions to reset your password. The + link will expire in 1 hour. + + + +
+ + + +
+
+
+ + {/* Back to Home */} +
+ + ← Back to home + +
+
+
+ ) + } + + return ( +
+
+ + Remember your password?{' '} + + Sign in + + + } + /> + + {/* Forgot Password Form */} + + + Enter your email + We'll send you a link to reset your password + + +
+ {error && ( + + {error} + + )} + +
+ + setEmail(e.target.value)} + placeholder="john@example.com" + /> +
+ + +
+
+
+ + {/* Back to Home */} +
+ + ← Back to home + +
+
+
+ ) +} diff --git a/src/app/(frontend)/layout.tsx b/src/app/(frontend)/layout.tsx new file mode 100644 index 0000000..5058792 --- /dev/null +++ b/src/app/(frontend)/layout.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import { CartProvider } from '@/lib/cart-context' +import { CartSidebar } from '@/components/cart-sidebar' +import { CookieBanner } from '@/components/cookie-banner' +import '../globals.css' + +export const metadata = { + description: 'A mini store template using Payload built with Dyad.', + title: 'Dyad Portal Mini Store Template', +} + +export default async function RootLayout(props: { children: React.ReactNode }) { + const { children } = props + + return ( + + + +
{children}
+ + +
+ + + ) +} diff --git a/src/app/(frontend)/login/page.tsx b/src/app/(frontend)/login/page.tsx new file mode 100644 index 0000000..730b105 --- /dev/null +++ b/src/app/(frontend)/login/page.tsx @@ -0,0 +1,177 @@ +'use client' + +import React, { useState, useEffect, Suspense } from 'react' +import Link from 'next/link' +import { useRouter, useSearchParams } from 'next/navigation' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { SiteHeader } from '@/components/site-header' + +function LoginForm() { + const [formData, setFormData] = useState({ + email: '', + password: '', + }) + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState('') + const [message, setMessage] = useState('') + const router = useRouter() + const searchParams = useSearchParams() + + useEffect(() => { + const messageParam = searchParams.get('message') + if (messageParam) { + setMessage(messageParam) + } + }, [searchParams]) + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target + setFormData((prev) => ({ + ...prev, + [name]: value, + })) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setIsSubmitting(true) + setError('') + + if (!formData.email || !formData.password) { + setError('Email and password are required') + setIsSubmitting(false) + return + } + + try { + const response = await fetch('/api/users/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email: formData.email, + password: formData.password, + }), + }) + + if (response.ok) { + // Login successful, redirect to home + router.push('/') + router.refresh() + } else { + const errorData = await response.json() + setError(errorData.message || 'Login failed. Please check your credentials.') + } + } catch (err) { + setError('Login failed. Please try again.') + } finally { + setIsSubmitting(false) + } + } + + return ( +
+
+ + Don't have an account?{' '} + + Sign up + + + } + /> + + {/* Login Form */} + + + Welcome back + Enter your email and password to sign in + + +
+ {message && ( + + {message} + + )} + + {error && ( + + {error} + + )} + +
+ + +
+ +
+
+ + + Forgot password? + +
+ +
+ + +
+
+
+ + {/* Back to Home */} +
+ + ← Back to home + +
+
+
+ ) +} + +export default function LoginPage() { + return ( + Loading... + } + > + + + ) +} diff --git a/src/app/(frontend)/my-orders/page.tsx b/src/app/(frontend)/my-orders/page.tsx new file mode 100644 index 0000000..e4c3978 --- /dev/null +++ b/src/app/(frontend)/my-orders/page.tsx @@ -0,0 +1,138 @@ +import { headers as getHeaders } from 'next/headers.js' +import Image from 'next/image' +import { getPayload } from 'payload' +import React from 'react' +import Link from 'next/link' +import { redirect } from 'next/navigation' + +import config from '@/payload.config' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Separator } from '@/components/ui/separator' + +export default async function MyOrdersPage({ + searchParams, +}: { + searchParams: Promise<{ success?: string }> +}) { + const { success } = await searchParams + const headers = await getHeaders() + const payloadConfig = await config + const payload = await getPayload({ config: payloadConfig }) + const { user } = await payload.auth({ headers }) + + // Redirect to login if not authenticated + if (!user) { + redirect('/login') + } + + // Fetch user's orders + const orders = await payload.find({ + collection: 'orders', + where: { + user: { + equals: user.id, + }, + }, + depth: 3, + sort: '-orderDate', + }) + + return ( +
+
+ + +

My Orders

+ + {success && ( + + Order placed successfully! 🎉 + + )} + + {orders.docs.length === 0 ? ( + + +

You haven't placed any orders yet.

+ +
+
+ ) : ( +
+ {orders.docs.map((order: any) => ( + + +
+ Order #{String(order.id).slice(-8)} + + {order.status.charAt(0).toUpperCase() + order.status.slice(1)} + +
+ + Ordered: {new Date(order.orderDate).toLocaleDateString()} + +
+ + +
+ {order.items.map((item: any, index: number) => ( +
+ {item.snack && + typeof item.snack === 'object' && + item.snack.image && + typeof item.snack.image === 'object' && + item.snack.image.url && ( +
+ {item.snack.image.alt +
+ )} +
+

{item.snack?.name || 'Unknown Item'}

+

Quantity: {item.quantity}

+

+ Price: ${(item.snack?.price * item.quantity || 0).toFixed(2)} +

+
+
+ ))} +
+ + + +
+ + Total: ${order.totalAmount.toFixed(2)} + +
+
+
+ ))} +
+ )} +
+
+ ) +} diff --git a/src/app/(frontend)/next/seed/route.ts b/src/app/(frontend)/next/seed/route.ts new file mode 100644 index 0000000..6593799 --- /dev/null +++ b/src/app/(frontend)/next/seed/route.ts @@ -0,0 +1,32 @@ +import { createLocalReq, getPayload } from 'payload' +import { seed } from '@/endpoints/seed' +import config from '@payload-config' +import { headers } from 'next/headers' +import { checkRole } from '@/collections/access' + +export const maxDuration = 60 // This function can run for a maximum of 60 seconds + +export async function POST(): Promise { + const payload = await getPayload({ config }) + const requestHeaders = await headers() + + // Authenticate by passing request headers + const { user } = await payload.auth({ headers: requestHeaders }) + + if (!user || !checkRole(['admin'], user)) { + return new Response('Action forbidden.', { status: 403 }) + } + + try { + // Create a Payload request object to pass to the Local API for transactions + // At this point you should pass in a user, locale, and any other context you need for the Local API + const payloadReq = await createLocalReq({ user }, payload) + + await seed({ payload, req: payloadReq }) + + return Response.json({ success: true }) + } catch (e) { + payload.logger.error({ err: e, message: 'Error seeding data' }) + return new Response('Error seeding data.', { status: 500 }) + } +} diff --git a/src/app/(frontend)/order/[id]/order-form.tsx b/src/app/(frontend)/order/[id]/order-form.tsx new file mode 100644 index 0000000..6007e30 --- /dev/null +++ b/src/app/(frontend)/order/[id]/order-form.tsx @@ -0,0 +1,111 @@ +'use client' + +import React, { useState } from 'react' +import { useRouter } from 'next/navigation' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Separator } from '@/components/ui/separator' + +interface OrderFormProps { + snack: any + user: any +} + +export default function OrderForm({ snack, user }: OrderFormProps) { + const [quantity, setQuantity] = useState(1) + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState('') + const router = useRouter() + + const totalPrice = (snack.price * quantity).toFixed(2) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setIsSubmitting(true) + setError('') + + try { + const response = await fetch('/api/orders', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + user: user.id, + items: [ + { + snack: snack.id, + quantity, + }, + ], + totalAmount: parseFloat(totalPrice), + }), + }) + + if (response.ok) { + router.push('/my-orders?success=true') + } else { + const errorData = await response.json() + setError(errorData.message || 'Failed to place order') + } + } catch (err) { + setError('Failed to place order. Please try again.') + } finally { + setIsSubmitting(false) + } + } + + return ( +
+
+ +
+ + setQuantity(Math.max(1, parseInt(e.target.value) || 1))} + min="1" + className="w-20 text-center" + /> + +
+
+ + + +
+

Total: ${totalPrice}

+
+ + {error && ( + + {error} + + )} + + + + ) +} diff --git a/src/app/(frontend)/order/[id]/page.tsx b/src/app/(frontend)/order/[id]/page.tsx new file mode 100644 index 0000000..9fdc5ac --- /dev/null +++ b/src/app/(frontend)/order/[id]/page.tsx @@ -0,0 +1,102 @@ +import { headers as getHeaders } from 'next/headers.js' +import Image from 'next/image' +import { getPayload } from 'payload' +import React from 'react' +import Link from 'next/link' +import { redirect } from 'next/navigation' + +import config from '@/payload.config' +import OrderForm from './order-form' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' + +interface OrderPageProps { + params: Promise<{ id: string }> +} + +export default async function OrderPage({ params }: OrderPageProps) { + const { id } = await params + const headers = await getHeaders() + const payloadConfig = await config + const payload = await getPayload({ config: payloadConfig }) + const { user } = await payload.auth({ headers }) + + // Redirect to login if not authenticated + if (!user) { + redirect('/login') + } + + // Fetch the specific snack + const snack = await payload.findByID({ + collection: 'snacks', + id, + depth: 2, + }) + + if (!snack || !snack.available) { + return ( +
+
+ + + Snack Not Available + Sorry, this snack is not available for ordering. + + + + + +
+
+ ) + } + + return ( +
+
+ + +
+ {/* Snack Details */} + + {snack.image && typeof snack.image === 'object' && snack.image.url && ( +
+ {snack.image.alt +
+ )} + +
+ {snack.name} + {snack.category} +
+ {snack.description} +
+ +

${snack.price.toFixed(2)} each

+
+
+ + {/* Order Form */} + + + Place Your Order + + + + + +
+
+
+ ) +} diff --git a/src/app/(frontend)/page.tsx b/src/app/(frontend)/page.tsx new file mode 100644 index 0000000..1852e2d --- /dev/null +++ b/src/app/(frontend)/page.tsx @@ -0,0 +1,296 @@ +import { headers as getHeaders } from 'next/headers.js' +import Image from 'next/image' +import { getPayload } from 'payload' +import React from 'react' +import Link from 'next/link' + +import config from '@/payload.config' +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Separator } from '@/components/ui/separator' +import { CartButton } from '@/components/cart-button' +import { AddToCartButton } from '@/components/add-to-cart-button' +import { LogoutButton } from '@/components/logout-button' +import { SiteHeader } from '@/components/site-header' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' + +export default async function HomePage() { + const headers = await getHeaders() + const payloadConfig = await config + const payload = await getPayload({ config: payloadConfig }) + const { user } = await payload.auth({ headers }) + + // Fetch all available snacks + const snacks = await payload.find({ + collection: 'snacks', + where: { + available: { + equals: true, + }, + }, + depth: 2, + }) + + return ( +
+ {/* Animated Background Elements */} +
+
+
+
+
+ + + +
+ {/* Hero Section */} +
+ {/* Floating Elements */} +
+
+
+
+
+
+ +
+
+
+

+ + Snacks + +
+ Reimagined +

+
+
+

+ Experience the future of snacking with our curated collection of premium treats, + delivered with precision and passion. +

+
+
+ + {/* Scroll Indicator */} +
+
+
+
+
+
+ +
+ {/* Snacks Grid */} +
+
+

+ Our Collection +

+

+ Handcrafted experiences, delivered to perfection +

+
+ + {snacks.docs.length === 0 ? ( +
+
+
+ 🔄 +
+

New experiences loading...

+
+
+ ) : ( +
+ {snacks.docs.map((snack: any, index: number) => ( + + + + {/* Enhanced Card Glow Effect */} +
+ + {/* Shimmer Effect */} +
+ +
+ {((snack.image && typeof snack.image === 'object') || snack.imageUrl) && ( +
+ { + {/* Image Overlay */} +
+ + {/* Floating Badge */} +
+ + {snack.category} + +
+
+ )} + + +
+ + {snack.name} + +
+
+ + {snack.description} + +
+ + +
+ + ${snack.price.toFixed(2)} + +

Premium Quality

+
+
+
+ Available +
+
+
+
+
+ + +
+ {/* Dialog Image */} + {((snack.image && typeof snack.image === 'object') || snack.imageUrl) && ( +
+ { +
+ + {/* Floating Badge in Dialog */} +
+ + {snack.category} + +
+
+ )} + + +
+ + {snack.name} + +
+
+ + + {snack.description} + + +
+
+ + ${snack.price.toFixed(2)} + +
+
+ + In Stock & Ready to Ship + +
+
+ +
+ {user ? ( + + ) : ( + + )} +
+
+
+
+
+
+ ))} +
+ )} +
+
+
+ + {/* Footer */} +
+
+
+

© 2024 Dyad Snacks. Crafted with passion.

+ +
+
+
+
+ ) +} diff --git a/src/app/(frontend)/register/page.tsx b/src/app/(frontend)/register/page.tsx new file mode 100644 index 0000000..7524995 --- /dev/null +++ b/src/app/(frontend)/register/page.tsx @@ -0,0 +1,212 @@ +'use client' + +import React, { useState } from 'react' +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { SiteHeader } from '@/components/site-header' + +export default function RegisterPage() { + const [formData, setFormData] = useState({ + firstName: '', + lastName: '', + email: '', + password: '', + confirmPassword: '', + }) + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState('') + const router = useRouter() + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target + setFormData((prev) => ({ + ...prev, + [name]: value, + })) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setIsSubmitting(true) + setError('') + + // Basic validation + if (!formData.firstName || !formData.lastName || !formData.email || !formData.password) { + setError('All fields are required') + setIsSubmitting(false) + return + } + + if (formData.password !== formData.confirmPassword) { + setError('Passwords do not match') + setIsSubmitting(false) + return + } + + if (formData.password.length < 6) { + setError('Password must be at least 6 characters long') + setIsSubmitting(false) + return + } + + try { + const response = await fetch('/api/users', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + firstName: formData.firstName, + lastName: formData.lastName, + email: formData.email, + password: formData.password, + role: 'user', + }), + }) + + if (response.ok) { + // Registration successful, redirect to login + router.push('/login?message=Registration successful! Please log in.') + } else { + const errorData = await response.json() + console.error('Registration error:', response.status, errorData) + setError( + errorData.message || + errorData.errors?.[0]?.message || + 'Registration failed. Please try again.', + ) + } + } catch (err) { + console.error('Registration fetch error:', err) + setError('Registration failed. Please try again.') + } finally { + setIsSubmitting(false) + } + } + + return ( +
+
+ + Already have an account?{' '} + + Sign in + + + } + /> + + {/* Registration Form */} + + + Sign up + Enter your information to create an account + + +
+ {error && ( + + {error} + + )} + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+
+ + {/* Back to Home */} +
+ + ← Back to home + +
+
+
+ ) +} diff --git a/src/app/(frontend)/reset-password/page.tsx b/src/app/(frontend)/reset-password/page.tsx new file mode 100644 index 0000000..7233c4b --- /dev/null +++ b/src/app/(frontend)/reset-password/page.tsx @@ -0,0 +1,205 @@ +'use client' + +import React, { useState, useEffect, Suspense } from 'react' +import Link from 'next/link' +import { useRouter, useSearchParams } from 'next/navigation' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { SiteHeader } from '@/components/site-header' + +function ResetPasswordForm() { + const [formData, setFormData] = useState({ + password: '', + confirmPassword: '', + }) + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState('') + const [success, setSuccess] = useState(false) + const router = useRouter() + const searchParams = useSearchParams() + const token = searchParams.get('token') + + useEffect(() => { + if (!token) { + setError('Invalid or missing reset token. Please request a new password reset.') + } + }, [token]) + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target + setFormData((prev) => ({ + ...prev, + [name]: value, + })) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setIsSubmitting(true) + setError('') + + if (!formData.password || !formData.confirmPassword) { + setError('Both password fields are required') + setIsSubmitting(false) + return + } + + if (formData.password !== formData.confirmPassword) { + setError('Passwords do not match') + setIsSubmitting(false) + return + } + + if (formData.password.length < 6) { + setError('Password must be at least 6 characters long') + setIsSubmitting(false) + return + } + + if (!token) { + setError('Invalid or missing reset token') + setIsSubmitting(false) + return + } + + try { + const response = await fetch('/api/users/reset-password', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + token, + password: formData.password, + }), + }) + + if (response.ok) { + setSuccess(true) + } else { + const errorData = await response.json() + setError(errorData.message || 'Failed to reset password. Please try again.') + } + } catch (err) { + setError('Failed to reset password. Please try again.') + } finally { + setIsSubmitting(false) + } + } + + if (success) { + return ( +
+
+ + + {/* Success Message */} + + + All set! + Your password has been successfully reset. + + + + You can now sign in with your new password. + + +
+ + + +
+
+
+ + {/* Back to Home */} +
+ + ← Back to home + +
+
+
+ ) + } + + return ( +
+
+ + + {/* Reset Password Form */} + + + New password + Choose a strong password for your account + + +
+ {error && ( + + {error} + + )} + +
+ + +
+ +
+ + +
+ + +
+
+
+ + {/* Back to Home */} +
+ + ← Back to home + +
+
+
+ ) +} + +export default function ResetPasswordPage() { + return ( + Loading... + } + > + + + ) +} diff --git a/src/app/(frontend)/snack/[id]/page.tsx b/src/app/(frontend)/snack/[id]/page.tsx new file mode 100644 index 0000000..2af1fd2 --- /dev/null +++ b/src/app/(frontend)/snack/[id]/page.tsx @@ -0,0 +1,86 @@ +import { headers as getHeaders } from 'next/headers.js' +import Image from 'next/image' +import { getPayload } from 'payload' +import React from 'react' +import config from '@/payload.config' +import { notFound } from 'next/navigation' +import { AddToCartButton } from '@/components/add-to-cart-button' +import { Button } from '@/components/ui/button' +import Link from 'next/link' +import { Badge } from '@/components/ui/badge' + +async function getSnack(id: string, payload: any) { + const snack = await payload.findByID({ + collection: 'snacks', + id, + depth: 2, + }) + return snack +} + +export default async function SnackPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const headers = await getHeaders() + const payloadConfig = await config + const payload = await getPayload({ config: payloadConfig }) + const { user } = await payload.auth({ headers }) + + const snack = await getSnack(id, payload) + + if (!snack) { + return notFound() + } + + return ( +
+
+
+
+ {((snack.image && typeof snack.image === 'object') || snack.imageUrl) && ( +
+ { +
+ )} +
+
+

{snack.name}

+
+ {snack.category} +
+

{snack.description}

+
+ ${snack.price.toFixed(2)} +
+
+ {user ? ( + + ) : ( + + )} +
+
+ +
+
+
+
+
+ ) +} diff --git a/src/app/(payload)/admin/[[...segments]]/not-found.tsx b/src/app/(payload)/admin/[[...segments]]/not-found.tsx new file mode 100644 index 0000000..6410836 --- /dev/null +++ b/src/app/(payload)/admin/[[...segments]]/not-found.tsx @@ -0,0 +1,24 @@ +/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ +/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ +import type { Metadata } from 'next' + +import config from '@payload-config' +import { NotFoundPage, generatePageMetadata } from '@payloadcms/next/views' +import { importMap } from '../importMap' + +type Args = { + params: Promise<{ + segments: string[] + }> + searchParams: Promise<{ + [key: string]: string | string[] + }> +} + +export const generateMetadata = ({ params, searchParams }: Args): Promise => + generatePageMetadata({ config, params, searchParams }) + +const NotFound = ({ params, searchParams }: Args) => + NotFoundPage({ config, params, searchParams, importMap }) + +export default NotFound diff --git a/src/app/(payload)/admin/[[...segments]]/page.tsx b/src/app/(payload)/admin/[[...segments]]/page.tsx new file mode 100644 index 0000000..0de685c --- /dev/null +++ b/src/app/(payload)/admin/[[...segments]]/page.tsx @@ -0,0 +1,24 @@ +/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ +/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ +import type { Metadata } from 'next' + +import config from '@payload-config' +import { RootPage, generatePageMetadata } from '@payloadcms/next/views' +import { importMap } from '../importMap' + +type Args = { + params: Promise<{ + segments: string[] + }> + searchParams: Promise<{ + [key: string]: string | string[] + }> +} + +export const generateMetadata = ({ params, searchParams }: Args): Promise => + generatePageMetadata({ config, params, searchParams }) + +const Page = ({ params, searchParams }: Args) => + RootPage({ config, params, searchParams, importMap }) + +export default Page diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js new file mode 100644 index 0000000..02c49fb --- /dev/null +++ b/src/app/(payload)/admin/importMap.js @@ -0,0 +1,5 @@ +import { default as default_9f32ce6f473387f99159899dd857e0af } from '@/components/before-dashboard' + +export const importMap = { + "@/components/before-dashboard#default": default_9f32ce6f473387f99159899dd857e0af +} diff --git a/src/app/(payload)/api/[...slug]/route.ts b/src/app/(payload)/api/[...slug]/route.ts new file mode 100644 index 0000000..e58c50f --- /dev/null +++ b/src/app/(payload)/api/[...slug]/route.ts @@ -0,0 +1,19 @@ +/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ +/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ +import config from '@payload-config' +import '@payloadcms/next/css' +import { + REST_DELETE, + REST_GET, + REST_OPTIONS, + REST_PATCH, + REST_POST, + REST_PUT, +} from '@payloadcms/next/routes' + +export const GET = REST_GET(config) +export const POST = REST_POST(config) +export const DELETE = REST_DELETE(config) +export const PATCH = REST_PATCH(config) +export const PUT = REST_PUT(config) +export const OPTIONS = REST_OPTIONS(config) diff --git a/src/app/(payload)/api/graphql-playground/route.ts b/src/app/(payload)/api/graphql-playground/route.ts new file mode 100644 index 0000000..17d2954 --- /dev/null +++ b/src/app/(payload)/api/graphql-playground/route.ts @@ -0,0 +1,7 @@ +/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ +/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ +import config from '@payload-config' +import '@payloadcms/next/css' +import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes' + +export const GET = GRAPHQL_PLAYGROUND_GET(config) diff --git a/src/app/(payload)/api/graphql/route.ts b/src/app/(payload)/api/graphql/route.ts new file mode 100644 index 0000000..2069ff8 --- /dev/null +++ b/src/app/(payload)/api/graphql/route.ts @@ -0,0 +1,8 @@ +/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ +/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ +import config from '@payload-config' +import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes' + +export const POST = GRAPHQL_POST(config) + +export const OPTIONS = REST_OPTIONS(config) diff --git a/src/app/(payload)/custom.scss b/src/app/(payload)/custom.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/(payload)/layout.tsx b/src/app/(payload)/layout.tsx new file mode 100644 index 0000000..8df141a --- /dev/null +++ b/src/app/(payload)/layout.tsx @@ -0,0 +1,31 @@ +/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ +/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ +import config from '@payload-config' +import '@payloadcms/next/css' +import type { ServerFunctionClient } from 'payload' +import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts' +import React from 'react' + +import { importMap } from './admin/importMap.js' +import './custom.scss' + +type Args = { + children: React.ReactNode +} + +const serverFunction: ServerFunctionClient = async function (args) { + 'use server' + return handleServerFunctions({ + ...args, + config, + importMap, + }) +} + +const Layout = ({ children }: Args) => ( + + {children} + +) + +export default Layout diff --git a/src/app/api/consent/route.ts b/src/app/api/consent/route.ts new file mode 100644 index 0000000..8fdf065 --- /dev/null +++ b/src/app/api/consent/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getPayload } from 'payload' +import config from '@/payload.config' + +/** + * POST /api/consent - Record consent action + * + * Request body: + * { + * action: 'accept' | 'reject' | 'update', + * purpose: 'analytics' | 'marketing' | 'functional' | 'all', + * analytics: boolean, + * marketing: boolean, + * functional: boolean, + * previousConsent?: { analytics: boolean, marketing: boolean, functional: boolean } + * } + */ +export async function POST(request: NextRequest) { + try { + const payloadConfig = await config + const payload = await getPayload({ config: payloadConfig }) + + const body = await request.json() + const { action, purpose, analytics, marketing, functional, previousConsent } = body + + // Validate required fields + if (!action || !['accept', 'reject', 'update'].includes(action)) { + return NextResponse.json({ error: 'Invalid action' }, { status: 400 }) + } + if (!purpose || !['analytics', 'marketing', 'functional', 'all'].includes(purpose)) { + return NextResponse.json({ error: 'Invalid purpose' }, { status: 400 }) + } + + // Get IP and User Agent + const ip = request.headers.get('x-forwarded-for')?.split(',')[0] + || request.headers.get('x-real-ip') + || 'unknown' + const userAgent = request.headers.get('user-agent') || 'unknown' + + // Create consent log + const consentLog = await payload.create({ + collection: 'consent-logs', + data: { + action, + purpose, + analytics: analytics ?? false, + marketing: marketing ?? false, + functional: functional ?? false, + userAgent, + ip, + timestamp: new Date().toISOString(), + previousConsent: previousConsent || null, + newConsent: { + analytics: analytics ?? false, + marketing: marketing ?? false, + functional: functional ?? false, + }, + }, + }) + + return NextResponse.json({ success: true, doc: consentLog }) + } catch (error) { + console.error('Consent logging error:', error) + return NextResponse.json({ error: 'Failed to log consent' }, { status: 500 }) + } +} + +/** + * GET /api/consent - Get current consent status (from cookie or localStorage) + * This endpoint is mainly for verification, actual consent is stored client-side + */ +export async function GET(request: NextRequest) { + // Consent is stored client-side in localStorage + // This endpoint is for compliance verification + return NextResponse.json({ + message: 'Consent is stored client-side', + purposes: ['analytics', 'marketing', 'functional'], + note: 'Use POST to update consent preferences' + }) +} diff --git a/src/app/api/orders/route.ts b/src/app/api/orders/route.ts new file mode 100644 index 0000000..3b5916c --- /dev/null +++ b/src/app/api/orders/route.ts @@ -0,0 +1,105 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getPayload } from 'payload' +import config from '@/payload.config' + +export async function POST(request: NextRequest) { + try { + const payloadConfig = await config + const payload = await getPayload({ config: payloadConfig }) + + // Get user from the request + const { user } = await payload.auth({ headers: request.headers }) + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const { items } = body + + if (!items || !Array.isArray(items) || items.length === 0) { + return NextResponse.json({ error: 'Invalid items' }, { status: 400 }) + } + + // Validate items exist and are available + let computedTotal = 0 + for (const item of items) { + const quantity = Number(item?.quantity) + const snackId = item?.snack + + if ( + !snackId || + !Number.isFinite(quantity) || + !Number.isInteger(quantity) || + quantity <= 0 || + quantity > 100 + ) { + return NextResponse.json({ error: 'Invalid item quantity or snack id' }, { status: 400 }) + } + + const snack = await payload.findByID({ + collection: 'snacks', + id: snackId, + }) + + if (!snack || !snack.available) { + return NextResponse.json({ error: `Snack ${snackId} is not available` }, { status: 400 }) + } + + const price = Number(snack.price) + if (!Number.isFinite(price) || price < 0) { + return NextResponse.json({ error: `Invalid price for snack ${snackId}` }, { status: 400 }) + } + + computedTotal += price * quantity + } + + // Create the order + const order = await payload.create({ + collection: 'orders', + data: { + user: user.id, + items, + totalAmount: computedTotal, + status: 'pending', + orderDate: new Date().toISOString(), + }, + }) + + return NextResponse.json({ success: true, doc: order }) + } catch (error) { + console.error('Order creation error:', error) + return NextResponse.json({ error: 'Failed to create order' }, { status: 500 }) + } +} + +export async function GET(request: NextRequest) { + try { + const payloadConfig = await config + const payload = await getPayload({ config: payloadConfig }) + + // Get user from the request + const { user } = await payload.auth({ headers: request.headers }) + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Get user's orders + const orders = await payload.find({ + collection: 'orders', + where: { + user: { + equals: user.id, + }, + }, + depth: 2, + sort: '-orderDate', + }) + + return NextResponse.json({ orders: orders.docs }) + } catch (error) { + console.error('Orders fetch error:', error) + return NextResponse.json({ error: 'Failed to fetch orders' }, { status: 500 }) + } +} diff --git a/src/app/api/orders/update-status/route.ts b/src/app/api/orders/update-status/route.ts new file mode 100644 index 0000000..72cf798 --- /dev/null +++ b/src/app/api/orders/update-status/route.ts @@ -0,0 +1,43 @@ +import { headers as getHeaders } from 'next/headers.js' +import { getPayload } from 'payload' +import { NextRequest, NextResponse } from 'next/server' + +import config from '@/payload.config' + +export async function PATCH(request: NextRequest) { + try { + const headers = await getHeaders() + const payloadConfig = await config + const payload = await getPayload({ config: payloadConfig }) + const { user } = await payload.auth({ headers }) + + if (!user || (user as any).role !== 'admin') { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const { orderId, status } = body + + if (!orderId || !status) { + return NextResponse.json({ message: 'Order ID and status are required' }, { status: 400 }) + } + + if (!['pending', 'completed', 'cancelled'].includes(status)) { + return NextResponse.json({ message: 'Invalid status' }, { status: 400 }) + } + + // Update the order + const updatedOrder = await payload.update({ + collection: 'orders', + id: orderId, + data: { + status, + }, + }) + + return NextResponse.json(updatedOrder, { status: 200 }) + } catch (error) { + console.error('Order status update error:', error) + return NextResponse.json({ message: 'Internal server error' }, { status: 500 }) + } +} diff --git a/src/app/api/users/forgot-password/route.ts b/src/app/api/users/forgot-password/route.ts new file mode 100644 index 0000000..1d55cf9 --- /dev/null +++ b/src/app/api/users/forgot-password/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getPayload } from 'payload' +import config from '@/payload.config' + +export async function POST(request: NextRequest) { + try { + const payloadConfig = await config + const payload = await getPayload({ config: payloadConfig }) + + const body = await request.json() + const { email } = body + + if (!email) { + return NextResponse.json({ message: 'Email is required' }, { status: 400 }) + } + + // Use Payload's built-in forgot password functionality + await payload.forgotPassword({ + collection: 'users', + data: { + email, + }, + }) + + // Always return success for security (don't reveal if email exists) + return NextResponse.json( + { + message: 'If an account with that email exists, we have sent a password reset link.', + }, + { status: 200 }, + ) + } catch (error) { + console.error('Forgot password error:', error) + // Always return success for security (don't reveal if email exists) + return NextResponse.json( + { + message: 'Error sending password reset email. Please check server logs.', + }, + { status: 500 }, + ) + } +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..8252242 --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,166 @@ +@import 'tailwindcss'; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} + +@keyframes gradient-x { + 0%, + 100% { + background-size: 200% 200%; + background-position: left center; + } + 50% { + background-size: 200% 200%; + background-position: right center; + } +} + +@keyframes fade-in { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-gradient-x { + animation: gradient-x 3s ease infinite; +} + +.animate-fade-in { + animation: fade-in 1s ease-out; +} + +.animation-delay-1000 { + animation-delay: 1s; +} + +.animation-delay-2000 { + animation-delay: 2s; +} + +.animation-delay-3000 { + animation-delay: 3s; +} + +.animation-delay-4000 { + animation-delay: 4s; +} diff --git a/src/app/my-route/route.ts b/src/app/my-route/route.ts new file mode 100644 index 0000000..0755886 --- /dev/null +++ b/src/app/my-route/route.ts @@ -0,0 +1,12 @@ +import configPromise from '@payload-config' +import { getPayload } from 'payload' + +export const GET = async (request: Request) => { + const payload = await getPayload({ + config: configPromise, + }) + + return Response.json({ + message: 'This is an example of a custom route.', + }) +} diff --git a/src/collections/ConsentLogs.ts b/src/collections/ConsentLogs.ts new file mode 100644 index 0000000..c929a2f --- /dev/null +++ b/src/collections/ConsentLogs.ts @@ -0,0 +1,122 @@ +import type { CollectionConfig } from 'payload' + +export interface ConsentLogData { + action: 'accept' | 'reject' | 'update' + purpose: 'analytics' | 'marketing' | 'functional' | 'all' + userAgent?: string + ip?: string + timestamp: string + previousConsent?: Record + newConsent?: Record +} + +const ConsentLogs: CollectionConfig = { + slug: 'consent-logs', + admin: { + useAsTitle: 'timestamp', + defaultColumns: ['timestamp', 'action', 'purpose', 'ip'], + description: 'Log of all consent actions for PDPA compliance', + }, + access: { + create: () => true, // Allow anyone to create consent logs (public endpoint) + read: () => true, // Allow reading for compliance purposes + update: () => false, // Consent logs should not be modified + delete: () => false, // Consent logs should not be deleted + }, + fields: [ + { + name: 'action', + type: 'select', + required: true, + options: [ + { label: 'Accept', value: 'accept' }, + { label: 'Reject', value: 'reject' }, + { label: 'Update', value: 'update' }, + ], + admin: { + description: 'The type of consent action', + }, + }, + { + name: 'purpose', + type: 'select', + required: true, + options: [ + { label: 'Analytics', value: 'analytics' }, + { label: 'Marketing', value: 'marketing' }, + { label: 'Functional', value: 'functional' }, + { label: 'All', value: 'all' }, + ], + admin: { + description: 'The purpose of the consent', + }, + }, + { + name: 'analytics', + type: 'checkbox', + defaultValue: false, + admin: { + description: 'Consent for analytics cookies', + }, + }, + { + name: 'marketing', + type: 'checkbox', + defaultValue: false, + admin: { + description: 'Consent for marketing cookies', + }, + }, + { + name: 'functional', + type: 'checkbox', + defaultValue: false, + admin: { + description: 'Consent for functional cookies', + }, + }, + { + name: 'userAgent', + type: 'text', + admin: { + readOnly: true, + description: 'Browser user agent string', + }, + }, + { + name: 'ip', + type: 'text', + admin: { + readOnly: true, + description: 'IP address of the user', + }, + }, + { + name: 'timestamp', + type: 'date', + required: true, + admin: { + readOnly: true, + description: 'When the consent was given', + }, + }, + { + name: 'previousConsent', + type: 'json', + admin: { + readOnly: true, + description: 'Previous consent state (for updates)', + }, + }, + { + name: 'newConsent', + type: 'json', + admin: { + readOnly: true, + description: 'New consent state', + }, + }, + ], +} + +export default ConsentLogs diff --git a/src/collections/Media.ts b/src/collections/Media.ts new file mode 100644 index 0000000..b358465 --- /dev/null +++ b/src/collections/Media.ts @@ -0,0 +1,21 @@ +import type { CollectionConfig } from 'payload' +import { admins, adminsOnly, anyone } from './access' + +export const Media: CollectionConfig = { + slug: 'media', + access: { + read: anyone, + create: admins, + update: admins, + delete: admins, + admin: adminsOnly, + }, + fields: [ + { + name: 'alt', + type: 'text', + required: true, + }, + ], + upload: true, +} diff --git a/src/collections/Orders.ts b/src/collections/Orders.ts new file mode 100644 index 0000000..60c8c3a --- /dev/null +++ b/src/collections/Orders.ts @@ -0,0 +1,67 @@ +import type { CollectionConfig } from 'payload' +import { admins, adminsOnly, adminsOrOwner, authenticated } from './access' + +export const Orders: CollectionConfig = { + slug: 'orders', + admin: { + useAsTitle: 'id', + }, + access: { + read: adminsOrOwner('user'), // Admins can read all orders, users can only read their own + create: authenticated, // Any authenticated user can create orders + update: admins, // Only admins can update orders + delete: admins, // Only admins can delete orders + admin: adminsOnly, + }, + fields: [ + { + name: 'user', + type: 'relationship', + relationTo: 'users', + required: true, + }, + { + name: 'items', + type: 'array', + fields: [ + { + name: 'snack', + type: 'relationship', + relationTo: 'snacks', + required: true, + }, + { + name: 'quantity', + type: 'number', + required: true, + min: 1, + }, + ], + required: true, + minRows: 1, + }, + { + name: 'status', + type: 'select', + options: [ + { label: 'Pending', value: 'pending' }, + { label: 'Completed', value: 'completed' }, + { label: 'Cancelled', value: 'cancelled' }, + ], + defaultValue: 'pending', + required: true, + }, + { + name: 'totalAmount', + type: 'number', + required: true, + min: 0, + }, + { + name: 'orderDate', + type: 'date', + defaultValue: () => new Date(), + required: true, + }, + ], +} diff --git a/src/collections/Snacks.ts b/src/collections/Snacks.ts new file mode 100644 index 0000000..7c02081 --- /dev/null +++ b/src/collections/Snacks.ts @@ -0,0 +1,67 @@ +import type { CollectionConfig } from 'payload' +import { admins, adminsOnly, anyone } from './access' + +export const Snacks: CollectionConfig = { + slug: 'snacks', + admin: { + useAsTitle: 'name', + }, + access: { + read: anyone, // Anyone can read snacks (for public viewing) + create: admins, + update: admins, + delete: admins, + admin: adminsOnly, + }, + fields: [ + { + name: 'name', + type: 'text', + required: true, + }, + { + name: 'description', + type: 'textarea', + required: true, + }, + { + name: 'price', + type: 'number', + required: true, + min: 0, + }, + { + name: 'image', + type: 'upload', + relationTo: 'media', + required: false, + }, + { + name: 'imageUrl', + type: 'text', + required: false, + admin: { + description: + 'Use this for placeholder images or external image URLs. Either image or imageUrl should be provided.', + }, + }, + { + name: 'available', + type: 'checkbox', + defaultValue: true, + }, + { + name: 'category', + type: 'select', + options: [ + { label: 'Chips', value: 'chips' }, + { label: 'Candy', value: 'candy' }, + { label: 'Cookies', value: 'cookies' }, + { label: 'Nuts', value: 'nuts' }, + { label: 'Crackers', value: 'crackers' }, + { label: 'Drinks', value: 'drinks' }, + ], + required: true, + }, + ], +} diff --git a/src/collections/Users.ts b/src/collections/Users.ts new file mode 100644 index 0000000..5a8e8f5 --- /dev/null +++ b/src/collections/Users.ts @@ -0,0 +1,67 @@ +import type { CollectionConfig } from 'payload' +import { admins, adminsOnly, adminsOrSelf, anyone, checkRole } from './access' + +export const Users: CollectionConfig = { + slug: 'users', + admin: { + useAsTitle: 'email', + }, + auth: { + forgotPassword: { + generateEmailHTML: (data) => { + const resetPasswordURL = `${data?.req?.payload.config.serverURL}/reset-password?token=${data?.token}` + + return ` + + + + You are receiving this because you (or someone else) have requested the reset of the password for your account. Please click on the following link, or paste this into your browser to complete the process: ${resetPasswordURL} If you did not request this, please ignore this email and your password will remain unchanged. + + + + ` + }, + }, + }, + access: { + create: anyone, // Allow anyone to create a user account (for registration) + read: adminsOrSelf, // Allow users to read their own profile, admins can read all + update: adminsOrSelf, // Allow users to update their own profile, admins can update all + admin: adminsOnly, + }, + fields: [ + { + name: 'role', + type: 'select', + options: [ + { + label: 'Admin', + value: 'admin', + }, + { + label: 'User', + value: 'user', + }, + ], + defaultValue: 'user', + required: true, + access: { + read: adminsOnly, + create: adminsOnly, + update: adminsOnly, + }, + }, + { + name: 'firstName', + type: 'text', + required: true, + }, + { + name: 'lastName', + type: 'text', + required: true, + }, + // Email added by default + // Add more fields as needed + ], +} diff --git a/src/collections/access/index.ts b/src/collections/access/index.ts new file mode 100644 index 0000000..101f094 --- /dev/null +++ b/src/collections/access/index.ts @@ -0,0 +1,44 @@ +import type { Access } from 'payload' +import type { User } from '../../payload-types' + +// Utility function to check if user has specific roles +export const checkRole = (allRoles: User['role'][] = [], user: User | null = null): boolean => { + if (user) { + if (allRoles.some((role) => user?.role === role)) { + return true + } + } + return false +} + +// Common access patterns +export const anyone: Access = () => true + +export const admins: Access = ({ req: { user } }) => checkRole(['admin'], user) + +export const adminsOnly = ({ req: { user } }: { req: { user: User | null } }) => + checkRole(['admin'], user) + +export const authenticated: Access = ({ req: { user } }) => !!user + +export const adminsOrSelf: Access = ({ req: { user } }) => { + if (!user) return false + if (checkRole(['admin'], user)) return true + return { + id: { + equals: user.id, + }, + } +} + +export const adminsOrOwner = (ownerField: string = 'user'): Access => { + return ({ req: { user } }) => { + if (!user) return false + if (checkRole(['admin'], user)) return true + return { + [ownerField]: { + equals: user.id, + }, + } + } +} diff --git a/src/components/add-to-cart-button.tsx b/src/components/add-to-cart-button.tsx new file mode 100644 index 0000000..29f631a --- /dev/null +++ b/src/components/add-to-cart-button.tsx @@ -0,0 +1,66 @@ +'use client' + +import React, { useState } from 'react' +import { Plus, Check } from 'lucide-react' + +import { useCart } from '@/lib/cart-context' +import { Button } from '@/components/ui/button' +import type { CartItem } from '@/lib/cart-context' + +interface AddToCartButtonProps { + snack: { + id: string + name: string + price: number + category: string + image?: { + url: string + alt?: string + } + } +} + +export const AddToCartButton: React.FC = ({ snack }) => { + const { addItem, openCart } = useCart() + const [isAdded, setIsAdded] = useState(false) + + const handleAddToCart = () => { + addItem({ + id: snack.id, + name: snack.name, + price: snack.price, + category: snack.category, + image: snack.image, + }) + + setIsAdded(true) + + // Show feedback for 1 second + setTimeout(() => { + setIsAdded(false) + }, 1000) + + // Optional: Open cart after adding item + // openCart() + } + + return ( + + ) +} diff --git a/src/components/before-dashboard/index.scss b/src/components/before-dashboard/index.scss new file mode 100644 index 0000000..7169b4f --- /dev/null +++ b/src/components/before-dashboard/index.scss @@ -0,0 +1,24 @@ +@import '~@payloadcms/ui/scss'; + +.dashboard .before-dashboard { + margin-bottom: base(1.5); + + &__banner { + & h4 { + margin: 0; + } + } + + &__instructions { + list-style: decimal; + margin-bottom: base(0.5); + + & li { + width: 100%; + } + } + + & a:hover { + opacity: 0.85; + } +} diff --git a/src/components/before-dashboard/index.tsx b/src/components/before-dashboard/index.tsx new file mode 100644 index 0000000..99ddcf5 --- /dev/null +++ b/src/components/before-dashboard/index.tsx @@ -0,0 +1,22 @@ +import { Banner } from '@payloadcms/ui/elements/Banner' +import React from 'react' + +import { SeedButton } from './seed-button' +import './index.scss' + +const baseClass = 'before-dashboard' + +const BeforeDashboard: React.FC = () => { + return ( +
+ +

Welcome to your dashboard!

+
+ Add some default snacks +
+ +
+ ) +} + +export default BeforeDashboard diff --git a/src/components/before-dashboard/seed-button.tsx b/src/components/before-dashboard/seed-button.tsx new file mode 100644 index 0000000..87abdf8 --- /dev/null +++ b/src/components/before-dashboard/seed-button.tsx @@ -0,0 +1,88 @@ +'use client' + +import React, { Fragment, useCallback, useState } from 'react' +import { toast } from '@payloadcms/ui' + +import './index.scss' + +const SuccessMessage: React.FC = () => ( +
+ Database seeded! You can now{' '} + + visit your website + +
+) + +export const SeedButton: React.FC = () => { + const [loading, setLoading] = useState(false) + const [seeded, setSeeded] = useState(false) + const [error, setError] = useState(null) + + const handleClick = useCallback( + async (e: React.MouseEvent) => { + e.preventDefault() + + if (seeded) { + toast.info('Database already seeded.') + return + } + if (loading) { + toast.info('Seeding already in progress.') + return + } + if (error) { + toast.error(`An error occurred, please refresh and try again.`) + return + } + + setLoading(true) + + try { + toast.promise( + new Promise((resolve, reject) => { + try { + fetch('/next/seed', { method: 'POST', credentials: 'include' }) + .then((res) => { + if (res.ok) { + resolve(true) + setSeeded(true) + } else { + reject('An error occurred while seeding.') + } + }) + .catch((error) => { + reject(error) + }) + } catch (error) { + reject(error) + } + }), + { + loading: 'Seeding with data....', + success: , + error: 'An error occurred while seeding.', + }, + ) + } catch (err) { + const error = err instanceof Error ? err.message : String(err) + setError(error) + } + }, + [loading, seeded, error], + ) + + let message = '' + if (loading) message = ' (seeding...)' + if (seeded) message = ' (done!)' + if (error) message = ` (error: ${error})` + + return ( + + + {message} + + ) +} diff --git a/src/components/cart-button.tsx b/src/components/cart-button.tsx new file mode 100644 index 0000000..5434ce6 --- /dev/null +++ b/src/components/cart-button.tsx @@ -0,0 +1,24 @@ +'use client' + +import React from 'react' +import { ShoppingCart } from 'lucide-react' + +import { useCart } from '@/lib/cart-context' +import { Button } from '@/components/ui/button' + +export const CartButton: React.FC = () => { + const { toggleCart, getTotalItems } = useCart() + const totalItems = getTotalItems() + + return ( + + ) +} diff --git a/src/components/cart-sidebar.tsx b/src/components/cart-sidebar.tsx new file mode 100644 index 0000000..20eae39 --- /dev/null +++ b/src/components/cart-sidebar.tsx @@ -0,0 +1,198 @@ +'use client' + +import React from 'react' +import Image from 'next/image' +import Link from 'next/link' +import { X, Plus, Minus, ShoppingCart } from 'lucide-react' + +import { useCart } from '@/lib/cart-context' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Separator } from '@/components/ui/separator' + +export const CartSidebar: React.FC = () => { + const { state, removeItem, updateQuantity, closeCart, getTotalItems, getTotalPrice } = useCart() + + if (!state.isOpen) return null + + return ( + <> + {/* Enhanced Overlay */} +
+ + {/* Sidebar */} +
+ {/* Header */} +
+
+

+
+ +
+ Your Cart ({getTotalItems()}) +

+ +
+
+ + {state.items.length === 0 ? ( + /* Empty Cart */ +
+
+
+ +
+
+

Your cart is empty

+

Add some delicious snacks to get started!

+
+ +
+
+ ) : ( + <> + {/* Cart Items */} +
+ {state.items.map((item, index) => ( + + {/* Card Glow Effect */} +
+ +
+
+ {/* Item Image */} + {item.image && ( +
+ {item.image.alt +
+ )} + + {/* Item Details */} +
+
+

+ {item.name} +

+ +
+ + + {item.category} + + +

+ ${item.price.toFixed(2)} each +

+ + {/* Quantity Controls */} +
+
+ + + {item.quantity} + + +
+
+
+
+ + {/* Item Total */} +
+ Subtotal: + + ${(item.price * item.quantity).toFixed(2)} + +
+
+
+ ))} +
+ + {/* Footer */} +
+
+ Total: + + ${getTotalPrice().toFixed(2)} + +
+ +
+ +
+ + +
+
+ + )} +
+ + ) +} diff --git a/src/components/cookie-banner.tsx b/src/components/cookie-banner.tsx new file mode 100644 index 0000000..3152b6b --- /dev/null +++ b/src/components/cookie-banner.tsx @@ -0,0 +1,316 @@ +'use client' + +import { useState, useEffect } from 'react' + +interface ConsentState { + analytics: boolean + marketing: boolean + functional: boolean + hasConsented: boolean + timestamp?: string +} + +const defaultConsent: ConsentState = { + analytics: false, + marketing: false, + functional: false, + hasConsented: false, +} + +const STORAGE_KEY = 'pdpa_consent' + +export function CookieBanner() { + const [consent, setConsent] = useState(defaultConsent) + const [showBanner, setShowBanner] = useState(false) + const [showPreferences, setShowPreferences] = useState(false) + + // Load consent from localStorage on mount + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) { + try { + const parsed = JSON.parse(stored) + setConsent(parsed) + setShowBanner(false) + } catch { + setShowBanner(true) + } + } else { + setShowBanner(true) + } + }, []) + + // Save consent to localStorage + const saveConsent = async (newConsent: ConsentState) => { + // Save to localStorage + localStorage.setItem(STORAGE_KEY, JSON.stringify(newConsent)) + setConsent(newConsent) + setShowBanner(false) + setShowPreferences(false) + + // Log to server + try { + await fetch('/api/consent', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: newConsent.hasConsented ? 'accept' : 'reject', + purpose: 'all', + ...newConsent, + }), + }) + } catch (error) { + console.error('Failed to log consent:', error) + } + } + + // Accept all cookies + const acceptAll = () => { + saveConsent({ + analytics: true, + marketing: true, + functional: true, + hasConsented: true, + timestamp: new Date().toISOString(), + }) + } + + // Reject all cookies (only functional) + const rejectAll = () => { + saveConsent({ + analytics: false, + marketing: false, + functional: false, + hasConsented: true, + timestamp: new Date().toISOString(), + }) + } + + // Save custom preferences + const savePreferences = () => { + saveConsent({ + ...consent, + hasConsented: true, + timestamp: new Date().toISOString(), + }) + } + + // Update individual preference + const updatePreference = (key: keyof Pick, value: boolean) => { + setConsent(prev => ({ ...prev, [key]: value })) + } + + // If no banner to show, return null + if (!showBanner) return null + + return ( +
+
+ {!showPreferences ? ( + // Main banner +
+

+ 🍪 PDPA Cookie Consent +

+

+ We use cookies to enhance your experience. By continuing to visit this site, you agree to our use of cookies.{' '} + + Learn more + +

+ +
+ + + + + +
+
+ ) : ( + // Preferences panel +
+

+ Cookie Preferences +

+ +

+ Manage your cookie preferences below. +

+ +
+ {/* Functional Cookies */} +
+
+
+

Functional Cookies

+

+ Essential for the website to function properly. Cannot be disabled. +

+
+
+ Always Active +
+
+
+ + {/* Analytics Cookies */} +
+
+
+

Analytics Cookies

+

+ Help us understand how visitors interact with our website. +

+
+ +
+
+ + {/* Marketing Cookies */} +
+
+
+

Marketing Cookies

+

+ Used to track visitors across websites for advertising purposes. +

+
+ +
+
+
+ +
+ + + +
+
+ )} +
+
+ ) +} diff --git a/src/components/logout-button.tsx b/src/components/logout-button.tsx new file mode 100644 index 0000000..c52933c --- /dev/null +++ b/src/components/logout-button.tsx @@ -0,0 +1,37 @@ +'use client' + +import React from 'react' +import { useRouter } from 'next/navigation' +import { Button } from '@/components/ui/button' + +export function LogoutButton() { + const router = useRouter() + + const handleLogout = async () => { + try { + const response = await fetch('/api/users/logout', { + method: 'POST', + }) + + if (response.ok) { + router.push('/') + router.refresh() + } else { + console.error('Logout failed') + } + } catch (error) { + console.error('An error occurred during logout', error) + } + } + + return ( + + ) +} diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx new file mode 100644 index 0000000..04362c6 --- /dev/null +++ b/src/components/site-header.tsx @@ -0,0 +1,86 @@ +import React from 'react' +import Link from 'next/link' +import { Button } from '@/components/ui/button' +import { CartButton } from '@/components/cart-button' +import { LogoutButton } from '@/components/logout-button' + +export interface SiteHeaderProps { + variant?: 'full' | 'simple' + user?: any + title?: string + subtitle?: string | React.ReactNode + className?: string +} + +export function SiteHeader({ + variant = 'simple', + user, + title, + subtitle, + className = '', +}: SiteHeaderProps) { + if (variant === 'full') { + return ( +
+
+ +
+ + 🍿 + +
+
+

+ Dyad Snacks +

+ + + {/* Navigation and User Actions */} +
+ {user ? ( + <> +
+ + Welcome, {user.firstName || user.email} + + + {user.role === 'admin' && ( + + )} +
+ + + + ) : ( +
+ + +
+ )} +
+
+
+ ) + } + + // Simple variant (for auth pages, etc.) + return ( +
+ + 🍿 Dyad Snacks + + {title &&

{title}

} + {subtitle &&
{subtitle}
} +
+ ) +} diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx new file mode 100644 index 0000000..4a8cca4 --- /dev/null +++ b/src/components/ui/accordion.tsx @@ -0,0 +1,66 @@ +"use client" + +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDownIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Accordion({ + ...props +}: React.ComponentProps) { + return +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..0863e40 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,157 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000..1421354 --- /dev/null +++ b/src/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/src/components/ui/aspect-ratio.tsx b/src/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..3df3fd0 --- /dev/null +++ b/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,11 @@ +"use client" + +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +function AspectRatio({ + ...props +}: React.ComponentProps) { + return +} + +export { AspectRatio } diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 0000000..71e428b --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,53 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..0205413 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/breadcrumb.tsx b/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..eb88f32 --- /dev/null +++ b/src/components/ui/breadcrumb.tsx @@ -0,0 +1,109 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { + return