diff --git a/AUTHENTICATION_SETUP.md b/AUTHENTICATION_SETUP.md deleted file mode 100644 index 7252989..0000000 --- a/AUTHENTICATION_SETUP.md +++ /dev/null @@ -1,194 +0,0 @@ -# Authentication Setup Guide - -This polling app uses Supabase for user authentication with email verification. Follow these steps to set up authentication: - -## 1. Create a Supabase Project - -1. Go to [supabase.com](https://supabase.com) and create a new account or sign in -2. Create a new project -3. Wait for the project to be set up - -## 2. Get Your Supabase Credentials - -1. In your Supabase dashboard, go to **Settings** > **API** -2. Copy the following values: - - **Project URL** (starts with `https://`) - - **anon public** key (starts with `eyJ`) - -## 3. Set Up Environment Variables - -Create a `.env.local` file in the root of your project with the following content: - -```env -NEXT_PUBLIC_SUPABASE_URL=your_project_url_here -NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key_here -``` - -Replace `your_project_url_here` and `your_anon_key_here` with the actual values from your Supabase project. - -## 4. Configure Supabase Authentication - -1. In your Supabase dashboard, go to **Authentication** > **Settings** -2. Under **Site URL**, add your development URL (e.g., `http://localhost:3000`) -3. Under **Redirect URLs**, add: - - `http://localhost:3000/auth/callback` - - `http://localhost:3000/login` - - `http://localhost:3000/register` - - `http://localhost:3000/verify-email` - -## 5. Enable Email Verification - -1. Go to **Authentication** > **Settings** -2. Under **Email Auth**, make sure **Enable email confirmations** is checked -3. Configure email templates (optional but recommended) - -## 6. Email Templates (Recommended) - -1. Go to **Authentication** > **Email Templates** -2. Customize the email templates for: - - **Confirm signup**: Welcome users and provide clear verification instructions - - **Reset password**: Help users reset their password securely - - **Magic link**: For passwordless authentication (optional) - -### Sample Email Template Content: - -**Confirm signup template:** - -``` -Subject: Verify your email address - Polling App - -Hi there, - -Thanks for signing up for Polling App! Please click the link below to verify your email address: - -[Confirm your email address]({{ .ConfirmationURL }}) - -This link will expire in 24 hours. - -If you didn't create an account, you can safely ignore this email. - -Best regards, -The Polling App Team -``` - -## 7. Start the Development Server - -```bash -npm run dev -``` - -## Features - -- **User Registration**: Users can create accounts with email and password -- **Email Verification**: Automatic email verification with resend functionality -- **User Login**: Secure authentication with email and password -- **Protected Routes**: Automatic redirection for unauthenticated users -- **User Profile**: Display user information and logout functionality -- **Form Validation**: Client-side validation with Zod -- **Toast Notifications**: User feedback for authentication actions -- **Middleware Protection**: Server-side route protection -- **Email Verification Page**: Dedicated page for email verification - -## Authentication Flow - -1. **Registration**: Users sign up with email, password, and full name -2. **Email Verification**: Users receive a verification email with a link -3. **Email Confirmation**: Users click the link to verify their email -4. **Login**: Users sign in with email and password (only after verification) -5. **Session Management**: Automatic session handling with cookies -6. **Logout**: Users can sign out and are redirected to login page - -## Email Verification Flow - -1. **User registers** → Verification email sent automatically -2. **User clicks email link** → Redirected to `/auth/callback` -3. **Email verified** → User redirected to `/polls` -4. **Email not verified** → User redirected to `/login` with error message -5. **Resend verification** → Users can request new verification emails - -## Components - -- `AuthProvider`: Context provider for authentication state -- `LoginForm`: Form component for user login with email verification reminder -- `RegisterForm`: Form component for user registration with email verification flow -- `EmailVerificationForm`: Component for resending verification emails -- `UserProfile`: Component for displaying user info and logout -- `ProtectedRoute`: Wrapper for protecting routes - -## Routes - -- `/login` - User login page -- `/register` - User registration page -- `/verify-email` - Email verification page -- `/auth/callback` - Authentication callback handler -- `/polls` - Protected dashboard (requires authentication) - -## Middleware - -The app includes middleware that: - -- Redirects unauthenticated users to `/login` -- Redirects authenticated users away from public routes (`/login`, `/register`, `/verify-email`) -- Handles session refresh automatically -- Protects all routes except public ones - -## Security Features - -- Server-side authentication checks -- Secure cookie handling -- Form validation and sanitization -- Protected API routes -- Automatic session management -- Email verification requirement -- CSRF protection via Supabase - -## Testing the Setup - -1. **Register a new user**: - - - Go to `/register` - - Fill out the form - - Check your email for verification link - -2. **Verify email**: - - - Click the verification link in your email - - You should be redirected to `/polls` - -3. **Login**: - - - Go to `/login` - - Sign in with your credentials - - You should be redirected to `/polls` - -4. **Test protected routes**: - - - Try accessing `/polls` without being logged in - - You should be redirected to `/login` - -5. **Test email verification**: - - Go to `/verify-email` - - Request a new verification email - - Check your email for the new link - -## Troubleshooting - -### Email not received: - -- Check spam folder -- Verify email address is correct -- Check Supabase email settings -- Try resending from `/verify-email` - -### Verification link not working: - -- Check redirect URLs in Supabase settings -- Ensure environment variables are correct -- Check browser console for errors - -### Login issues: - -- Make sure email is verified -- Check password is correct -- Verify Supabase project is active -- Check network connectivity diff --git a/DATABASE_SETUP.md b/DATABASE_SETUP.md deleted file mode 100644 index f61066e..0000000 --- a/DATABASE_SETUP.md +++ /dev/null @@ -1,111 +0,0 @@ -# Database Setup Guide - -## Setting up Supabase Database Schema - -Follow these steps to create the required database tables and functions for the Polling App: - -### 1. Access Supabase SQL Editor - -1. Go to your [Supabase Dashboard](https://supabase.com/dashboard) -2. Select your project -3. Navigate to **SQL Editor** in the left sidebar -4. Click **New Query** - -### 2. Run the Schema Script - -1. Copy the entire contents of `supabase-schema.sql` -2. Paste it into the SQL Editor -3. Click **Run** to execute the script - -### 3. Verify Tables Creation - -After running the script, verify these tables were created: - -- `profiles` - User profile information -- `polls` - Main polls table -- `poll_options` - Poll voting options -- `poll_votes` - Individual votes cast by users - -### 4. Check Row Level Security (RLS) - -The script automatically enables RLS and creates policies for: -- Public read access to polls and options -- User-specific write permissions -- Vote tracking and validation - -### 5. Environment Variables - -Ensure your `.env.local` file contains: - -```env -NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url -NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key -SUPABASE_SECRET_KEY=your_supabase_service_role_key -NEXT_PUBLIC_SITE_URL=http://localhost:3000 -``` - -### 6. Test the Setup - -1. Start your development server: `npm run dev` -2. Register a new user account -3. Try creating a poll -4. Test voting functionality - -### Database Schema Overview - -#### Tables Structure: - -**profiles** -- `id` (UUID, references auth.users) -- `full_name` (TEXT) -- `avatar_url` (TEXT) -- `created_at`, `updated_at` (TIMESTAMP) - -**polls** -- `id` (UUID, primary key) -- `title`, `description` (TEXT) -- `category`, `status` (TEXT) -- `created_by` (UUID, references auth.users) -- `total_votes` (INTEGER) -- `allow_multiple_votes`, `anonymous_voting` (BOOLEAN) -- `created_at`, `updated_at`, `ends_at` (TIMESTAMP) - -**poll_options** -- `id` (UUID, primary key) -- `poll_id` (UUID, references polls) -- `text` (TEXT) -- `votes` (INTEGER) -- `order_index` (INTEGER) - -**poll_votes** -- `id` (UUID, primary key) -- `poll_id`, `option_id` (UUID, references) -- `user_id` (UUID, references auth.users) -- `ip_address` (INET) -- `created_at` (TIMESTAMP) - -### Automatic Features - -The schema includes: -- **Auto vote counting** - Triggers update vote counts automatically -- **Profile creation** - New user profiles created on signup -- **Timestamp updates** - `updated_at` fields auto-updated -- **Data integrity** - Foreign key constraints and unique constraints -- **Security** - RLS policies for data access control - -### Troubleshooting - -If you encounter issues: - -1. **Permission errors**: Check RLS policies are correctly applied -2. **Foreign key errors**: Ensure user is authenticated when creating polls -3. **Vote counting issues**: Verify triggers are active -4. **Connection errors**: Check environment variables - -Run this query to check if tables exist: -```sql -SELECT table_name -FROM information_schema.tables -WHERE table_schema = 'public' -AND table_name IN ('profiles', 'polls', 'poll_options', 'poll_votes'); -``` diff --git a/ENV_SETUP.md b/ENV_SETUP.md deleted file mode 100644 index 317e957..0000000 --- a/ENV_SETUP.md +++ /dev/null @@ -1,53 +0,0 @@ -# Environment Variables Setup - -To fix the runtime error and get the authentication system working, you need to set up your environment variables. - -## Quick Setup - -1. **Create a `.env.local` file** in the root of your project (same level as `package.json`) - -2. **Add the following content** to your `.env.local` file: - -```env -# Supabase Configuration -NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co -NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here -NEXT_PUBLIC_SITE_URL=http://localhost:3000 -``` - -3. **Replace the placeholder values** with your actual Supabase credentials: - - Get your project URL from Supabase Dashboard → Settings → API - - Get your anon key from Supabase Dashboard → Settings → API - -## Example `.env.local` file: - -```env -NEXT_PUBLIC_SUPABASE_URL=https://abcdefghijklmnop.supabase.co -NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImFiY2RlZmdoaWprbG1ub3AiLCJyb2xlIjoiYW5vbiIsImlhdCI6MTYzNjU2NzI5MCwiZXhwIjoxOTUyMTQzMjkwfQ.example -NEXT_PUBLIC_SITE_URL=http://localhost:3000 -``` - -## After Setup - -1. **Restart your development server**: - - ```bash - npm run dev - ``` - -2. **The runtime error should be resolved** and the authentication system will work properly. - -## Getting Supabase Credentials - -1. Go to [supabase.com](https://supabase.com) -2. Create a new project or select an existing one -3. Go to **Settings** → **API** -4. Copy the **Project URL** and **anon public** key -5. Paste them in your `.env.local` file - -## Troubleshooting - -- **Make sure the file is named exactly `.env.local`** (with the dot at the beginning) -- **Restart your development server** after creating the file -- **Check the browser console** for any remaining errors -- **Verify your Supabase project is active** and the credentials are correct diff --git a/README.md b/README.md index 56c6672..ee70895 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ pnpm install ### 3. Environment Variables -Create a `.env.local` file in the root directory: +Create a `.env.local` file in the root of your project with the following content. You can get the `SUPABASE_URL` and `SUPABASE_ANON_KEY` from your Supabase project's **Settings > API** page. ```env # Supabase Configuration @@ -64,12 +64,18 @@ SUPABASE_SECRET_KEY=your_supabase_secret_key NEXT_PUBLIC_SITE_URL=http://localhost:3000 ``` -### 4. Supabase Setup +### 4. Supabase Database Setup -1. Create a new project in [Supabase](https://supabase.com) -2. Run the database schema from `supabase-schema.sql` -3. Set up authentication providers (email/password is enabled by default) -4. Configure email templates for verification (optional) +1. Go to the **SQL Editor** in your Supabase dashboard. +2. Create a **New Query**. +3. Run the schema scripts from the `/migrations` directory in order. This will create the necessary tables (`profiles`, `polls`, `poll_options`, `poll_votes`), enable Row Level Security (RLS), and set up required policies. + +### 5. Supabase Authentication Setup + +1. In your Supabase dashboard, go to **Authentication > Settings**. +2. Under **Site URL**, add your development URL: `http://localhost:3000`. +3. Under **Redirect URLs**, add your callback URL: `http://localhost:3000/auth/callback`. +4. Enable **Email confirmations** under the **Email Auth** section. ### 5. Database Schema @@ -96,6 +102,36 @@ pnpm dev Open [http://localhost:3000](http://localhost:3000) to view the application. +### Environment & Supabase Setup + +1. Create `.env.local` with: + +```env +# Supabase Configuration +NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url +NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key +SUPABASE_SECRET_KEY=your_supabase_secret_key +NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY} + +# Site Configuration +NEXT_PUBLIC_SITE_URL=http://localhost:3000 + +# (Optional) Email Provider +# RESEND_API_KEY=your_resend_key +# SENDGRID_API_KEY=your_sendgrid_key +``` + +2. Apply database migrations in Supabase SQL Editor (or CLI): + +- `migrations/0002_add_user_roles.sql` (roles + RLS) +- `migrations/0003_add_email_to_profiles.sql` (email on profiles) +- `migrations/0004_create_poll_shares.sql` (share analytics + RLS) +- `migrations/0005_create_comments.sql` (comments + RLS) + +3. Configure Auth (Email/Password enabled) and set Redirect URL to `${NEXT_PUBLIC_SITE_URL}/auth/callback`. + +4. Optional: seed admin role using Supabase SQL (set `profiles.role = 'admin'`). + ### Production Build ```bash @@ -135,7 +171,7 @@ npm start - Copy the direct link - Share on social media (Twitter, Facebook, LinkedIn) - Generate QR code for mobile sharing -3. **Track Engagement**: Monitor vote counts and sharing statistics +3. **Track Engagement**: Monitor vote counts and sharing statistics (Shares today, Total shares) ### Managing Your Polls @@ -198,6 +234,16 @@ alx-polly/ - **Error Handling**: User-friendly error messages and fallbacks - **Accessibility**: WCAG compliant with proper ARIA labels +## 🤖 AI Usage (Tools and Context) + +- Development assistance leveraged AI for code generation and refactoring with context on: + - Next.js App Router best practices (Server Components, Server Actions) + - Supabase SSR client usage and RLS-safe queries + - Error handling and redirect patterns (handling `NEXT_REDIRECT` digest) + - QR code integration (`qrcode.react`) and analytics design +- Tools used in-editor: code search, lints, and automated edits; no secrets were hardcoded. +- All database keys are loaded via environment variables. + ## 🚀 Deployment ### Vercel (Recommended) @@ -206,6 +252,15 @@ alx-polly/ 2. Set environment variables in Vercel dashboard 3. Deploy automatically on every push to main branch +### Production Readiness Notes + +- Environment variables set in hosting provider (same as `.env.local`). +- Ensure middleware allows public access to `/polls` and `/auth/callback` for QR usage. +- Confirm DB migrations applied and RLS policies enabled. +- Confirm `poll_shares` and `comments` tables exist and policies applied. +- Set `NEXT_PUBLIC_SITE_URL` to production domain for correct email redirects and QR URLs. +- If enabling email notifications, set `RESEND_API_KEY` or `SENDGRID_API_KEY`. + ### Other Platforms The app can be deployed to any platform that supports Next.js: diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index c9f4882..dd8dfd3 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,6 +1,9 @@ import { Metadata } from "next" import Link from "next/link" import { LoginForm } from "@/components/auth/login-form" +import { AuthLayout } from "@/components/auth/auth-layout" +import { LoginSidePanel } from "@/components/auth/login-side-panel" +import { AuthGuard } from "@/components/auth/auth-guard" export const metadata: Metadata = { title: "Login | Polling App", @@ -9,41 +12,26 @@ export const metadata: Metadata = { export default function LoginPage() { return ( -
-
-
-
- Polling App -
-
-
-

- "Create polls, gather opinions, and make data-driven decisions with our intuitive polling platform." -

-
-
-
-
-
-
-

- Welcome back -

-

- Enter your credentials to access your account -

-
- -

- - Don't have an account? Sign up - + + }> +

+

+ Welcome back +

+

+ Enter your credentials to access your account

-
-
+ +

+ + Don't have an account? Sign up + +

+ + ) } diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index eab6638..372119a 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -1,6 +1,9 @@ import { Metadata } from "next" import Link from "next/link" import { RegisterForm } from "@/components/auth/register-form" +import { AuthLayout } from "@/components/auth/auth-layout" +import { RegisterSidePanel } from "@/components/auth/register-side-panel" +import { AuthGuard } from "@/components/auth/auth-guard" export const metadata: Metadata = { title: "Register | Polling App", @@ -9,41 +12,26 @@ export const metadata: Metadata = { export default function RegisterPage() { return ( -
-
-
-
- Polling App -
-
-
-

- "Join thousands of users who trust our platform for creating meaningful polls and gathering insights." -

-
-
-
-
-
-
-

- Create an account -

-

- Enter your information below to create your account -

-
- -

- - Already have an account? Sign in - + + }> +

+

+ Create an account +

+

+ Enter your information below to create your account

-
-
+ +

+ + Already have an account? Sign in + +

+ + ) } diff --git a/app/(dashboard)/admin-test/page.tsx b/app/(dashboard)/admin-test/page.tsx new file mode 100644 index 0000000..552d222 --- /dev/null +++ b/app/(dashboard)/admin-test/page.tsx @@ -0,0 +1,144 @@ +import { Metadata } from "next"; +import { createServerComponentClient } from "@/lib/supabase-server"; +import { redirect } from "next/navigation"; + +export const metadata: Metadata = { + title: "Admin Test | Polling App", + description: "Test admin access and role detection", +}; + +async function getUserProfile(userId: string) { + const supabase = await createServerComponentClient(); + + const { data: profile, error } = await supabase + .from("profiles") + .select("id, full_name, role") + .eq("id", userId) + .single(); + + return { profile, error }; +} + +export default async function AdminTestPage() { + const supabase = await createServerComponentClient(); + + // Check if user is authenticated + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + redirect("/login"); + } + + // Get user profile and check if user has admin access + const { profile, error: profileError } = await getUserProfile(user.id); + + return ( +
+

Admin Access Test

+ +
+
+

User Authentication

+

+ User ID: {user?.id} +

+

+ Email: {user?.email} +

+

+ Auth Error:{" "} + {authError ? String(authError) : "None"} +

+
+ +
+

Profile Data

+ {profileError ? ( +
+

+ Profile Error: {profileError.message} +

+

+ Error Code: {profileError.code} +

+

+ Error Details: {profileError.details} +

+
+ ) : profile ? ( +
+

+ Profile ID: {profile.id} +

+

+ Full Name: {profile.full_name} +

+

+ Role:{" "} + + {profile.role} + +

+

+ Is Admin/Moderator:{" "} + {profile.role === "admin" || profile.role === "moderator" + ? "✅ YES" + : "❌ NO"} +

+
+ ) : ( +

Profile data is null

+ )} +
+ +
+

Admin Access Check

+ {profile && + (profile.role === "admin" || profile.role === "moderator") ? ( +
+

✅ ADMIN ACCESS GRANTED

+

You should be able to access the admin panel.

+ + Go to Admin Panel + +
+ ) : ( +
+

❌ ADMIN ACCESS DENIED

+

+ Your role ({profile?.role || "unknown"}) does not have admin + privileges. +

+
+ )} +
+ +
+

Database Query Test

+

If you see a role above, the database query is working.

+

If there's an error, check that:

+
    +
  • The profiles table exists
  • +
  • The role column exists
  • +
  • The role migration has been applied
  • +
  • RLS policies allow reading profiles
  • +
+
+ +
+

Quick Fix

+

To make this user an admin, run this SQL in Supabase:

+ + UPDATE public.profiles SET role = 'admin' WHERE id = '{user.id}'; + +
+
+
+ ); +} diff --git a/app/(dashboard)/admin/page.tsx b/app/(dashboard)/admin/page.tsx new file mode 100644 index 0000000..1b4141d --- /dev/null +++ b/app/(dashboard)/admin/page.tsx @@ -0,0 +1,70 @@ +import { Metadata } from "next"; +import { createServerComponentClient } from "@/lib/supabase-server"; +import { redirect } from "next/navigation"; +import { AdminDashboard } from "@/components/admin/admin-dashboard"; +import { RoleProvider } from "@/contexts/role-context"; +import { DashboardShell } from "@/components/layout/dashboard-shell"; +import { UserRole } from "@/lib/types/roles"; + +export const metadata: Metadata = { + title: "Admin Dashboard | Polling App", + description: "Manage users and system settings", +}; + +async function getUserProfile(userId: string) { + const supabase = await createServerComponentClient(); + + const { data: profile, error } = await supabase + .from("profiles") + .select("id, full_name, role") + .eq("id", userId) + .single(); + + console.log("getUserProfile Debug:", { userId, profile, error }); + + return { profile, error }; +} + +export default async function AdminPage() { + const supabase = await createServerComponentClient(); + + // Check if user is authenticated + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + console.log("Admin Page Auth Debug:", { user: user?.id, authError }); + + if (authError || !user) { + console.log("Redirecting to login - no user"); + redirect("/login"); + } + + // Get user profile and check if user has admin access + const { profile, error: profileError } = await getUserProfile(user.id); + + console.log("Admin Page Profile Debug:", { profile, profileError }); + + if (profileError || !profile) { + console.log("Profile error or no profile, redirecting to dashboard"); + redirect("/dashboard"); + } + + if (profile.role !== "admin" && profile.role !== "moderator") { + console.log( + `Role ${profile.role} not authorized, redirecting to dashboard`, + ); + redirect("/dashboard"); + } + + console.log(`Admin access granted for role: ${profile.role}`); + + return ( + + + + + + ); +} diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx index dfc59e6..1df7437 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/(dashboard)/dashboard/page.tsx @@ -1,11 +1,10 @@ import { Metadata } from "next" import { Suspense } from "react" -import { DashboardHeader } from "@/components/layout/dashboard-header" -import { DashboardShell } from "@/components/layout/dashboard-shell" -import { PollsList } from "@/components/polls/polls-list" -import { CreatePollButton } from "@/components/polls/create-poll-button" -import { SuccessMessage } from "@/components/ui/success-message" -import { getUserPolls } from "@/lib/polls/actions" +import { DashboardHeader } from "@/components/layout/dashboard-header"; +import { DashboardShell } from "@/components/layout/dashboard-shell"; +import { CreatePollButton } from "@/components/polls/create-poll-button"; +import { SuccessMessage } from "@/components/ui/success-message"; +import { DashboardStatsStreaming } from "@/components/streaming/dashboard-stats-streaming"; export const metadata: Metadata = { title: "Dashboard | Polling App", @@ -13,82 +12,23 @@ export const metadata: Metadata = { } export default async function DashboardPage() { - const polls = await getUserPolls() - - // Calculate statistics - const totalPolls = polls.length - const totalVotes = polls.reduce((sum, poll) => sum + poll.totalVotes, 0) - const activePolls = polls.filter(poll => poll.status === 'active').length - const avgVotes = totalPolls > 0 ? Math.round(totalVotes / totalPolls) : 0 return ( - + {/* Success Message */} - +
- -
- {/* Stats cards with real data */} -
-
-

Total Polls

-
-
{totalPolls}
-

- Polls you've created -

-
-
-
-

Total Votes

-
-
{totalVotes}
-

- Across all your polls -

-
-
-
-

Active Polls

-
-
{activePolls}
-

- Currently accepting votes -

-
-
-
-

Avg. Votes

-
-
{avgVotes}
-

- Per poll average -

-
+ +
-
-
-
-

- Recent Polls -

-

- Your latest polls and their current status -

-
-
- -
-
-
diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..b732570 --- /dev/null +++ b/app/(dashboard)/layout.tsx @@ -0,0 +1,9 @@ +import { ReactNode } from "react"; + +interface DashboardLayoutProps { + children: ReactNode; +} + +export default function DashboardLayout({ children }: DashboardLayoutProps) { + return <>{children}; +} diff --git a/app/api/debug/site-url/route.ts b/app/api/debug/site-url/route.ts new file mode 100644 index 0000000..c107c2b --- /dev/null +++ b/app/api/debug/site-url/route.ts @@ -0,0 +1,38 @@ +import { getSiteUrl } from '@/lib/utils/site-url'; +import { NextResponse } from 'next/server'; + +export async function GET() { + try { + // Get the site URL using our utility + const siteUrl = getSiteUrl(); + + // Collect environment information for debugging + const debugInfo = { + siteUrl, + env: { + NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL || 'NOT_SET', + VERCEL_URL: process.env.VERCEL_URL || 'NOT_SET', + NETLIFY_URL: process.env.NETLIFY_URL || 'NOT_SET', + RAILWAY_PUBLIC_DOMAIN: process.env.RAILWAY_PUBLIC_DOMAIN || 'NOT_SET', + RENDER_EXTERNAL_URL: process.env.RENDER_EXTERNAL_URL || 'NOT_SET', + NODE_ENV: process.env.NODE_ENV || 'NOT_SET', + }, + callbackUrl: `${siteUrl}/auth/callback`, + timestamp: new Date().toISOString(), + }; + + return NextResponse.json(debugInfo); + } catch (error) { + return NextResponse.json( + { + error: 'Failed to get site URL', + message: error instanceof Error ? error.message : 'Unknown error', + env: { + NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL || 'NOT_SET', + NODE_ENV: process.env.NODE_ENV || 'NOT_SET', + } + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/polls/[id]/route.ts b/app/api/polls/[id]/route.ts index cac1672..f47463a 100644 --- a/app/api/polls/[id]/route.ts +++ b/app/api/polls/[id]/route.ts @@ -1,111 +1,31 @@ -import { createServerComponentClient } from "@/lib/supabase-server" -import { Poll } from "@/lib/types/poll" -import { revalidatePath } from "next/cache" -import { NextResponse } from "next/server" +import { NextRequest, NextResponse } from 'next/server'; +import { getPollServer } from '@/lib/data/polls-server'; -// GET /api/polls/[id] - Fetches a single poll by ID -export async function GET(request: Request, { params }: { params: { id: string } }) { +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { try { - const { id } = params - const supabase = await createServerComponentClient() + const { id } = await params; + const poll = await getPollServer(id); - const { data, error } = await supabase - .from("polls") - .select(` - *, - poll_options ( - id, - text, - votes, - order_index - ) - `) - .eq("id", id) - .single() - - if (error) { - if (error.code === 'PGRST116') { - return NextResponse.json({ error: "Poll not found" }, { status: 404 }) - } - throw new Error(`Failed to fetch poll: ${error.message}`) - } - - if (!data) { - return NextResponse.json({ error: "Poll not found" }, { status: 404 }) + if (!poll) { + return NextResponse.json( + { error: 'Poll not found' }, + { status: 404 } + ); } - - // Transform database response with calculated percentages - const poll: Poll = { - id: data.id, - title: data.title, - description: data.description, - category: data.category, - status: data.status, - createdAt: data.created_at, - endsAt: data.ends_at, - totalVotes: data.total_votes, - allowMultipleVotes: data.allow_multiple_votes, - anonymousVoting: data.anonymous_voting, - createdBy: data.created_by, - options: data.poll_options?.map((option: any) => ({ - id: option.id, - text: option.text, - votes: option.votes, - orderIndex: option.order_index, - // Calculate percentage for results visualization - percentage: data.total_votes > 0 ? Math.round((option.votes / data.total_votes) * 100) : 0 - })) || [] - } - - return NextResponse.json(poll) - } catch (error) { - console.error("Error fetching poll:", error) - const errorMessage = error instanceof Error ? error.message : "An unknown error occurred" - return NextResponse.json({ error: errorMessage }, { status: 500 }) - } -} - -// DELETE /api/polls/[id] - Deletes a poll -export async function DELETE(request: Request, { params }: { params: { id: string } }) { - try { - const { id: pollId } = params - const supabase = await createServerComponentClient() - - const { data: { user }, error: userError } = await supabase.auth.getUser() - if (userError || !user) { - return NextResponse.json({ error: "Authentication required" }, { status: 401 }) - } - - const { data: poll, error: pollError } = await supabase - .from("polls") - .select("created_by") - .eq("id", pollId) - .single() - - if (pollError || !poll) { - return NextResponse.json({ error: "Poll not found" }, { status: 404 }) - } - - if (poll.created_by !== user.id) { - return NextResponse.json({ error: "You can only delete your own polls" }, { status: 403 }) - } - - const { error: deleteError } = await supabase - .from("polls") - .delete() - .eq("id", pollId) - - if (deleteError) { - throw new Error(`Failed to delete poll: ${deleteError.message}`) - } - - revalidatePath("/polls") - revalidatePath("/dashboard") - return NextResponse.json({ message: "Poll deleted successfully!" }, { status: 200 }) + return NextResponse.json(poll, { + headers: { + 'Cache-Control': 'public, max-age=60, s-maxage=60, stale-while-revalidate=300', + }, + }); } catch (error) { - console.error("Error deleting poll:", error) - const errorMessage = error instanceof Error ? error.message : "An unknown error occurred" - return NextResponse.json({ error: errorMessage }, { status: 500 }) + console.error('Error fetching poll:', error); + return NextResponse.json( + { error: 'Failed to fetch poll' }, + { status: 500 } + ); } } \ No newline at end of file diff --git a/app/api/polls/[id]/vote/route.ts b/app/api/polls/[id]/vote/route.ts index 9e52997..602183c 100644 --- a/app/api/polls/[id]/vote/route.ts +++ b/app/api/polls/[id]/vote/route.ts @@ -1,22 +1,34 @@ -import { createServerComponentClient } from "@/lib/supabase-server" -import { revalidatePath } from "next/cache" -import { NextResponse } from "next/server" +import { createServerComponentClient } from "@/lib/supabase-server"; +import { revalidatePath } from "next/cache"; +import { NextResponse } from "next/server"; // POST /api/polls/[id]/vote - Records user votes for a poll -export async function POST(request: Request, { params }: { params: { id: string } }) { +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { try { - const { id: pollId } = params - const { optionIds } = await request.json() + const { id: pollId } = await params; + const { optionIds } = await request.json(); if (!optionIds || !Array.isArray(optionIds) || optionIds.length === 0) { - return NextResponse.json({ error: "At least one optionId is required" }, { status: 400 }) + return NextResponse.json( + { error: "At least one optionId is required" }, + { status: 400 }, + ); } - const supabase = await createServerComponentClient() - - const { data: { user }, error: userError } = await supabase.auth.getUser() + const supabase = await createServerComponentClient(); + + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); if (userError || !user) { - return NextResponse.json({ error: "Authentication required" }, { status: 401 }) + return NextResponse.json( + { error: "Authentication required" }, + { status: 401 }, + ); } // Check if user has already voted on this poll @@ -25,10 +37,13 @@ export async function POST(request: Request, { params }: { params: { id: string .select("id") .eq("poll_id", pollId) .eq("user_id", user.id) - .limit(1) + .limit(1); if (existingVote && existingVote.length > 0) { - return NextResponse.json({ error: "You have already voted on this poll" }, { status: 409 }) // 409 Conflict + return NextResponse.json( + { error: "You have already voted on this poll" }, + { status: 409 }, + ); // 409 Conflict } // Get poll configuration to validate voting rules @@ -36,42 +51,49 @@ export async function POST(request: Request, { params }: { params: { id: string .from("polls") .select("allow_multiple_votes, status") .eq("id", pollId) - .single() + .single(); if (pollError || !poll) { - return NextResponse.json({ error: "Poll not found" }, { status: 404 }) + return NextResponse.json({ error: "Poll not found" }, { status: 404 }); } // Ensure poll is still accepting votes if (poll.status !== "active") { - return NextResponse.json({ error: "This poll is no longer active" }, { status: 403 }) + return NextResponse.json( + { error: "This poll is no longer active" }, + { status: 403 }, + ); } // Validate multiple vote selection against poll settings if (!poll.allow_multiple_votes && optionIds.length > 1) { - return NextResponse.json({ error: "Only one option can be selected for this poll" }, { status: 400 }) + return NextResponse.json( + { error: "Only one option can be selected for this poll" }, + { status: 400 }, + ); } // Create vote records - one for each selected option const voteInserts = optionIds.map((optionId: string) => ({ poll_id: pollId, user_id: user.id, - option_id: optionId - })) + option_id: optionId, + })); const { error: voteError } = await supabase .from("poll_votes") - .insert(voteInserts) + .insert(voteInserts); if (voteError) { - throw new Error(`Failed to record vote: ${voteError.message}`) + throw new Error(`Failed to record vote: ${voteError.message}`); } - revalidatePath(`/polls/${pollId}`) - return NextResponse.json({ success: true }, { status: 200 }) + revalidatePath(`/polls/${pollId}`); + return NextResponse.json({ success: true }, { status: 200 }); } catch (error) { - console.error("Error voting on poll:", error) - const errorMessage = error instanceof Error ? error.message : "An unknown error occurred" - return NextResponse.json({ error: errorMessage }, { status: 500 }) + console.error("Error voting on poll:", error); + const errorMessage = + error instanceof Error ? error.message : "An unknown error occurred"; + return NextResponse.json({ error: errorMessage }, { status: 500 }); } -} \ No newline at end of file +} diff --git a/app/api/polls/route.ts b/app/api/polls/route.ts index 62347dd..ca14211 100644 --- a/app/api/polls/route.ts +++ b/app/api/polls/route.ts @@ -1,184 +1,34 @@ -import { createServerComponentClient } from "@/lib/supabase-server" -import { Poll, PollFilters } from "@/lib/types/poll" -import { revalidatePath } from "next/cache" -import { NextResponse } from "next/server" +import { NextRequest, NextResponse } from 'next/server'; +import { getPollsOptimized } from '@/lib/data/optimized-polls-server'; +import { PollFilters } from '@/lib/types/poll'; -// GET /api/polls - Fetches polls with optional filtering and sorting -export async function GET(request: Request) { +export async function GET(request: NextRequest) { try { - const { searchParams } = new URL(request.url) - const filters: PollFilters = { - category: searchParams.get("category") || undefined, - status: searchParams.get("status") || undefined, - search: searchParams.get("search") || undefined, - sortBy: searchParams.get("sortBy") || "created_at", - sortOrder: searchParams.get("sortOrder") || "desc", - } - - let supabase - try { - supabase = await createServerComponentClient() - } catch (err) { - console.error("Supabase client creation failed:", err) - return NextResponse.json({ error: "Supabase client creation failed. Check environment variables and network." }, { status: 500 }) - } - - let query = supabase - .from("polls") - .select(` - *, - poll_options ( - id, - text, - votes, - order_index - ) - `) - - // Apply category filter if specified - if (filters?.category) { - query = query.eq("category", filters.category) - } - - // Apply status filter if specified - if (filters?.status) { - query = query.eq("status", filters.status) - } - - // Apply text search across title and description - if (filters?.search) { - query = query.or(`title.ilike.%${filters.search}%,description.ilike.%${filters.search}%`) - } - - // Apply sorting based on user preference - const sortBy = filters?.sortBy || "created_at" - const sortOrder = filters?.sortOrder || "desc" - - if (sortBy === "votes") { - query = query.order("total_votes", { ascending: sortOrder === "asc" }) - } else if (sortBy === "ending") { - query = query.order("ends_at", { ascending: sortOrder === "asc" }) - } else { - query = query.order("created_at", { ascending: sortOrder === "asc" }) - } - - let data, error - try { - const result = await query - data = result.data - error = result.error - } catch (err) { - console.error("Supabase fetch failed:", err) - return NextResponse.json({ error: "Supabase fetch failed. Check network or Supabase status." }, { status: 502 }) - } - - if (error) { - console.error("Supabase returned error:", error) - return NextResponse.json({ error: `Failed to fetch polls: ${error.message}` }, { status: 500 }) - } - - // Transform database response to match Poll interface - const polls: Poll[] = data?.map(poll => ({ - id: poll.id, - title: poll.title, - description: poll.description, - category: poll.category, - status: poll.status, - createdAt: poll.created_at, - endsAt: poll.ends_at, - totalVotes: poll.total_votes, - allowMultipleVotes: poll.allow_multiple_votes, - anonymousVoting: poll.anonymous_voting, - createdBy: poll.created_by, - options: poll.poll_options?.map((option: any) => ({ - id: option.id, - text: option.text, - votes: option.votes, - orderIndex: option.order_index - })) || [] - })) || [] - - return NextResponse.json(polls) - } catch (error) { - console.error("Error fetching polls:", error) - const errorMessage = error instanceof Error ? error.message : "An unknown error occurred" - return NextResponse.json({ error: errorMessage }, { status: 500 }) - } -} - -// POST /api/polls - Creates a new poll -export async function POST(request: Request) { - try { - const supabase = await createServerComponentClient() + const { searchParams } = new URL(request.url); - const { data: { user }, error: userError } = await supabase.auth.getUser() - if (userError || !user) { - return NextResponse.json({ error: "Authentication required" }, { status: 401 }) - } - - const formData = await request.formData() - const title = formData.get("title") as string - const description = formData.get("description") as string - const category = formData.get("category") as string - const endDate = formData.get("endDate") as string - const allowMultipleVotes = formData.get("allowMultipleVotes") === "on" - const anonymousVoting = formData.get("anonymousVoting") === "on" + const sortBy = searchParams.get('sortBy'); + const sortOrder = searchParams.get('sortOrder'); - const options: string[] = [] - let optionIndex = 0 - while (formData.get(`option-${optionIndex}`) !== null) { - const option = formData.get(`option-${optionIndex}`) as string - if (option.trim()) { - options.push(option.trim()) - } - optionIndex++ - } - - if (!title || !category || options.length < 2) { - return NextResponse.json({ error: "Title, category, and at least 2 options are required" }, { status: 400 }) - } - - const { data: poll, error: pollError } = await supabase - .from("polls") - .insert({ - title, - description: description || null, - category, - status: "active", // New polls start as active - ends_at: endDate || null, // Optional end date - allow_multiple_votes: allowMultipleVotes, - anonymous_voting: anonymousVoting, - created_by: user.id, - total_votes: 0 // Initialize vote count - }) - .select() - .single() - - if (pollError) { - throw new Error(`Failed to create poll: ${pollError.message}`) - } - - const optionInserts = options.map((optionText, index) => ({ - poll_id: poll.id, - text: optionText, - votes: 0, // Initialize vote count for each option - order_index: index // Maintain option order - })) - - const { error: optionsError } = await supabase - .from("poll_options") - .insert(optionInserts) - - if (optionsError) { - throw new Error(`Failed to create poll options: ${optionsError.message}`) - } - - revalidatePath("/polls") + const filters: PollFilters = { + category: searchParams.get('category') || undefined, + status: searchParams.get('status') || undefined, + search: searchParams.get('search') || undefined, + sortBy: (sortBy === 'created' || sortBy === 'votes' || sortBy === 'ending') ? sortBy as 'created' | 'votes' | 'ending' : undefined, + sortOrder: (sortOrder === 'asc' || sortOrder === 'desc') ? sortOrder as 'asc' | 'desc' : undefined, + }; + + const polls = await getPollsOptimized(filters); - return NextResponse.json({ message: "Poll created successfully!", pollId: poll.id }, { status: 201 }) + return NextResponse.json(polls, { + headers: { + 'Cache-Control': 'public, max-age=60, s-maxage=60, stale-while-revalidate=300', + }, + }); } catch (error) { - console.error("Error creating poll:", error) - const errorMessage = error instanceof Error ? error.message : "An unknown error occurred" - return NextResponse.json({ error: errorMessage }, { status: 500 }) + console.error('Error fetching polls:', error); + return NextResponse.json( + { error: 'Failed to fetch polls' }, + { status: 500 } + ); } } \ No newline at end of file diff --git a/app/api/polls/user/stats/route.ts b/app/api/polls/user/stats/route.ts new file mode 100644 index 0000000..5972184 --- /dev/null +++ b/app/api/polls/user/stats/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getUserPollsStatsServer } from '@/lib/data/polls-server'; +import { createServerComponentClient } from '@/lib/supabase-server'; + +export async function GET(request: NextRequest) { + try { + const supabase = await createServerComponentClient(); + const { data: { user } } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const stats = await getUserPollsStatsServer(user.id); + + return NextResponse.json(stats, { + headers: { + 'Cache-Control': 'private, max-age=300, stale-while-revalidate=600', + }, + }); + } catch (error) { + console.error('Error fetching user stats:', error); + return NextResponse.json( + { error: 'Failed to fetch user stats' }, + { status: 500 } + ); + } +} diff --git a/app/globals.css b/app/globals.css index dc98be7..f820cb3 100644 --- a/app/globals.css +++ b/app/globals.css @@ -4,119 +4,722 @@ @custom-variant dark (&:is(.dark *)); @theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); - --color-sidebar-ring: var(--sidebar-ring); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar: var(--sidebar); - --color-chart-5: var(--chart-5); - --color-chart-4: var(--chart-4); - --color-chart-3: var(--chart-3); - --color-chart-2: var(--chart-2); - --color-chart-1: var(--chart-1); - --color-ring: var(--ring); - --color-input: var(--input); - --color-border: var(--border); - --color-destructive: var(--destructive); - --color-accent-foreground: var(--accent-foreground); - --color-accent: var(--accent); - --color-muted-foreground: var(--muted-foreground); - --color-muted: var(--muted); - --color-secondary-foreground: var(--secondary-foreground); - --color-secondary: var(--secondary); - --color-primary-foreground: var(--primary-foreground); - --color-primary: var(--primary); - --color-popover-foreground: var(--popover-foreground); - --color-popover: var(--popover); - --color-card-foreground: var(--card-foreground); - --color-card: var(--card); - --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); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); } :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); + --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); + --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; - } + * { + @apply border-border outline-ring/50; + } + + html { + scroll-behavior: smooth; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + body { + @apply bg-background text-foreground; + /* Prevent layout shift during theme transitions */ + transition: + background-color 0.3s ease, + color 0.3s ease; + } + + /* Loading animations */ + @keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + @keyframes slide-in-from-top { + from { + transform: translateY(-10px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } + } + + @keyframes slide-in-from-right { + from { + transform: translateX(10px); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } + } + + .animate-fade-in { + animation: fade-in 0.3s ease-in-out; + } + + .animate-slide-in-from-top { + animation: slide-in-from-top 0.4s ease-out; + } + + .animate-slide-in-from-right { + animation: slide-in-from-right 0.4s ease-out; + } + + /* Focus improvements for accessibility */ + :focus-visible { + outline: 2px solid hsl(var(--ring)); + outline-offset: 2px; + } + + /* Smooth transitions for interactive elements */ + button, + [role="button"], + input, + select, + textarea { + transition: all 0.2s ease-in-out; + } + + /* Prevent flash of unstyled content */ + .theme-transition { + transition: none !important; + } + + /* Performance optimizations */ + .gpu-accelerated { + transform: translateZ(0); + will-change: transform; + } + + /* Loading spinner optimization */ + @keyframes spin-optimized { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } + + .animate-spin-fast { + animation: spin-optimized 0.75s linear infinite; + } + + /* Shimmer animation for progress bars */ + @keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } + } + + .animate-shimmer { + animation: shimmer 2s ease-in-out infinite; + } + + /* Enhanced loading animations */ + @keyframes bounce-dots { + 0%, + 20%, + 50%, + 80%, + 100% { + transform: translateY(0); + } + 40% { + transform: translateY(-8px); + } + 60% { + transform: translateY(-4px); + } + } + + @keyframes pulse-scale { + 0%, + 100% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.05); + opacity: 0.8; + } + } + + @keyframes fade-slide-in { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + @keyframes fade-slide-out { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(-20px); + } + } + + .animate-bounce-dots { + animation: bounce-dots 1.4s ease-in-out infinite; + } + + .animate-pulse-scale { + animation: pulse-scale 2s ease-in-out infinite; + } + + .animate-fade-slide-in { + animation: fade-slide-in 0.3s ease-out; + } + + .animate-fade-slide-out { + animation: fade-slide-out 0.3s ease-in; + } + + /* Loading screen backdrop blur optimization */ + .loading-backdrop { + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + } + + /* Prevent text selection on loading screens */ + .loading-screen { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + } + + /* Loading state transitions */ + .loading-transition { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + } + + /* Auth page animations - enhanced for new backgrounds */ + @keyframes float-gentle { + 0%, 100% { + transform: translateY(0px) translateX(0px); + } + 25% { + transform: translateY(-10px) translateX(5px); + } + 50% { + transform: translateY(-5px) translateX(-3px); + } + 75% { + transform: translateY(-15px) translateX(2px); + } + } + + @keyframes pulse-glow { + 0%, 100% { + opacity: 0.4; + transform: scale(1); + } + 50% { + opacity: 0.8; + transform: scale(1.05); + } + } + + @keyframes float-slow { + 0%, 100% { + transform: translateY(0px) rotate(0deg); + } + 33% { + transform: translateY(-8px) rotate(2deg); + } + 66% { + transform: translateY(8px) rotate(-2deg); + } + } + + @keyframes mesh-rotate { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } + + @keyframes gradient-shift { + 0%, 100% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + } + + @keyframes text-glow { + 0%, 100% { + text-shadow: 0 0 5px rgba(255, 255, 255, 0.1); + } + 50% { + text-shadow: 0 0 20px rgba(255, 255, 255, 0.3), 0 0 30px rgba(255, 255, 255, 0.1); + } + } + + @keyframes data-bar-grow { + 0% { + width: 0%; + opacity: 0; + } + 50% { + opacity: 1; + } + 100% { + width: var(--final-width); + opacity: 1; + } + } + + @keyframes counter-up { + 0% { + transform: translateY(20px); + opacity: 0; + } + 100% { + transform: translateY(0); + opacity: 1; + } + } + + .animate-float-gentle { + animation: float-gentle 6s ease-in-out infinite; + } + + .animate-pulse-glow { + animation: pulse-glow 3s ease-in-out infinite; + } + + .animate-float-slow { + animation: float-slow 8s ease-in-out infinite; + } + + .animate-mesh-rotate { + animation: mesh-rotate 120s linear infinite; + } + + .animate-gradient-shift { + background-size: 400% 400%; + animation: gradient-shift 8s ease infinite; + } + + .animate-text-glow { + animation: text-glow 4s ease-in-out infinite; + } + + .animate-data-bar { + animation: data-bar-grow 2s ease-out forwards; + } + + .animate-counter-up { + animation: counter-up 1s ease-out forwards; + } + + /* Enhanced backdrop effects for auth pages */ + .auth-backdrop { + backdrop-filter: blur(12px) saturate(180%); + -webkit-backdrop-filter: blur(12px) saturate(180%); + } + + .auth-glass { + background: rgba(255, 255, 255, 0.05); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.1); + } + + /* Responsive text scaling for auth pages */ + @media (max-width: 768px) { + .auth-text-responsive { + font-size: 1.5rem; + line-height: 1.4; + } + + .auth-subtitle-responsive { + font-size: 0.875rem; + line-height: 1.5; + } + } + + /* Enhanced viewport optimization for auth pages - improved for compact fit */ + @media (max-height: 900px) { + .auth-compact { + padding-top: 0.5rem; + padding-bottom: 0.5rem; + } + + .auth-compact .space-y-4 > * + * { + margin-top: 0.5rem; + } + + .auth-compact .space-y-3 > * + * { + margin-top: 0.25rem; + } + + .auth-compact .space-y-2 > * + * { + margin-top: 0.125rem; + } + } + + @media (max-height: 800px) { + .auth-compact { + padding-top: 0.375rem; + padding-bottom: 0.375rem; + } + + .auth-compact .space-y-3 > * + * { + margin-top: 0.25rem; + } + + .auth-compact .space-y-2 > * + * { + margin-top: 0.125rem; + } + + /* Side panel optimizations */ + .auth-side-panel .space-y-8 > * + * { + margin-top: 1.5rem; + } + } + + @media (max-height: 700px) { + .auth-compact { + padding-top: 0.25rem; + padding-bottom: 0.25rem; + } + + .auth-compact .space-y-3 > * + * { + margin-top: 0.125rem; + } + + .auth-compact .space-y-2 > * + * { + margin-top: 0.0625rem; + } + + /* Side panel optimizations for smaller heights */ + .auth-side-panel .space-y-8 > * + * { + margin-top: 1rem; + } + + .auth-side-panel .space-y-4 > * + * { + margin-top: 0.75rem; + } + } + + /* Mobile-specific viewport and form improvements */ + @media (max-width: 640px) { + /* Ensure mobile forms don't cause horizontal scroll */ + .auth-compact { + padding-left: 0.75rem; + padding-right: 0.75rem; + } + + /* Optimize input sizing for mobile keyboards */ + input[type="email"], + input[type="password"], + input[type="text"] { + font-size: 16px; /* Prevents iOS zoom */ + line-height: 1.5; + } + + /* Mobile-friendly button sizing */ + button { + font-size: 16px; + padding-top: 12px; + padding-bottom: 12px; + } + + /* Reduce background effects on mobile for performance */ + .animate-float-gentle, + .animate-pulse-glow, + .animate-float-slow { + animation-duration: 8s; + } + + .animate-mesh-rotate { + animation: none; /* Disable heavy rotation on mobile */ + } + } + + @media (max-width: 480px) { + /* Extra small mobile optimizations */ + .auth-compact { + padding-left: 0.5rem; + padding-right: 0.5rem; + } + + /* Tighter spacing for very small screens */ + .auth-compact .space-y-3 > * + * { + margin-top: 0.25rem; + } + + .auth-compact .space-y-4 > * + * { + margin-top: 0.375rem; + } + } + + /* Medium screen specific optimizations for single-column layout */ + @media (max-width: 1023px) { + /* Ensure content fits in viewport on medium screens */ + .lg\:hidden { + padding-top: 1rem; + padding-bottom: 1rem; + } + + /* Optimize form spacing for medium screens */ + .lg\:hidden .space-y-3 > * + * { + margin-top: 0.5rem; + } + + /* Compact titles and headers on medium screens */ + .lg\:hidden h1 { + margin-bottom: 0.5rem; + } + + .lg\:hidden p { + margin-bottom: 0.75rem; + } + } + + /* Mobile-first responsive utilities */ + @screen xs { + .xs\:max-w-\[360px\] { + max-width: 360px; + } + + .xs\:p-5 { + padding: 1.25rem; + } + } + + /* Touch-friendly improvements */ + @media (hover: none) and (pointer: coarse) { + /* Increase tap targets on touch devices */ + button, + [role="button"], + input[type="button"], + input[type="submit"], + a { + min-height: 44px; + min-width: 44px; + } + + /* Remove hover effects on touch devices */ + .hover\:bg-white\/10:hover { + background-color: transparent; + } + + /* Ensure form inputs are comfortable for touch */ + input, + textarea, + select { + font-size: 16px; /* Prevents zoom on iOS */ + padding: 12px; + } + } + + /* High contrast mode support for accessibility */ + @media (prefers-contrast: high) { + .border { + border-width: 2px; + } + + .shadow-sm, + .shadow, + .shadow-md, + .shadow-lg, + .shadow-xl { + box-shadow: 0 0 0 1px currentColor; + } + } + + /* Motion preferences and accessibility */ + @media (prefers-reduced-motion: reduce) { + .animate-float-gentle, + .animate-pulse-glow, + .animate-gradient-shift, + .animate-text-glow, + .animate-counter-up { + animation: none; + } + + .animate-pulse { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; + } + + /* Remove transforms for reduced motion */ + .transition-all, + .transition-colors, + .transition-transform { + transition: none !important; + } + } + + /* Improved skeleton animations */ + @keyframes skeleton-wave { + 0% { + transform: translateX(-100%); + } + 50% { + transform: translateX(100%); + } + 100% { + transform: translateX(100%); + } + } + + .skeleton-shimmer { + position: relative; + overflow: hidden; + } + + .skeleton-shimmer::after { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + transform: translateX(-100%); + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.2), + transparent + ); + animation: skeleton-wave 1.6s ease-in-out 0.5s infinite; + content: ""; + } + + .dark .skeleton-shimmer::after { + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.1), + transparent + ); + } } diff --git a/app/layout.tsx b/app/layout.tsx index 06d53b7..82a6a49 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,23 +1,55 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +// Removed Google Fonts for better performance - using system fonts import "./globals.css"; import { AuthProvider } from "@/contexts/auth-context"; -import { ThemeProvider } from "@/components/theme/theme-provider"; +import { ThemeProvider, ThemeScript } from "@/components/theme/theme-provider"; +import { + LoadingProvider, +} from "@/components/providers/smart-loading-provider"; import { Toaster } from "sonner"; -const geistSans = Geist({ +// Use system fonts as fallback for better performance +const geistSans = { variable: "--font-geist-sans", - subsets: ["latin"], -}); + className: "font-sans", +}; -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); +const geistMono = { + variable: "--font-geist-mono", + className: "font-mono", +}; export const metadata: Metadata = { - title: "Polling App", + title: { + default: "Polling App", + template: "%s | Polling App", + }, description: "Create polls, gather opinions, and make data-driven decisions", + keywords: ["polls", "voting", "surveys", "data collection", "opinions"], + authors: [{ name: "Polling App Team" }], + creator: "Polling App", + metadataBase: new URL( + process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000", + ), + openGraph: { + type: "website", + locale: "en_US", + url: process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000", + title: "Polling App", + description: + "Create polls, gather opinions, and make data-driven decisions", + siteName: "Polling App", + }, + twitter: { + card: "summary_large_image", + title: "Polling App", + description: + "Create polls, gather opinions, and make data-driven decisions", + }, + robots: { + index: true, + follow: true, + }, }; export default function RootLayout({ @@ -26,20 +58,47 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + + + + - - {children} - - + + +
+ {children} +
+ +
+
diff --git a/app/loading.tsx b/app/loading.tsx new file mode 100644 index 0000000..e167257 --- /dev/null +++ b/app/loading.tsx @@ -0,0 +1,10 @@ +import { MinimalLoading } from '@/components/ui/minimal-loading'; + +export default function Loading() { + return ( +
+ +
+ ); +} + diff --git a/app/polls/[id]/page.tsx b/app/polls/[id]/page.tsx index 05d3991..fc2b1a4 100644 --- a/app/polls/[id]/page.tsx +++ b/app/polls/[id]/page.tsx @@ -3,7 +3,13 @@ import { notFound } from "next/navigation" import { PollView } from "@/components/polls/poll-view" import { PollResults } from "@/components/polls/poll-results" import { SharePoll } from "@/components/polls/share-poll" -import { getPoll } from "@/lib/polls/actions" +import { getPoll, getPollShareStats } from "@/lib/polls/queries" +import { createServerComponentClient } from "@/lib/supabase-server" +import { PollChat } from "@/components/polls/poll-chat" +import { CommentList } from "@/components/polls/comment-list" +import { CommentForm } from "@/components/polls/comment-form" +import { addComment } from "@/lib/polls/comments" +import { submitVoteAction } from "@/lib/polls/vote-actions" interface PollPageProps { params: Promise<{ @@ -31,20 +37,59 @@ export async function generateMetadata({ params }: PollPageProps): Promise
- - -
- - -
+ {/* Landmark: Main voting area */} +
+ +
+ + {/* Results + Share: responsive two-column grid */} +
+
+ +
+
+ +
+
+ + {/* Discussion: Comments and Chat */} +
+
+ + +
+
+ +
+
) diff --git a/app/polls/create/page.tsx b/app/polls/create/page.tsx index b95938f..e19535c 100644 --- a/app/polls/create/page.tsx +++ b/app/polls/create/page.tsx @@ -1,5 +1,6 @@ import { Metadata } from "next" import { CreatePollForm } from "@/components/polls/create-poll-form" +import { createPoll } from "@/lib/polls/actions" export const metadata: Metadata = { title: "Create Poll | Polling App", @@ -7,6 +8,10 @@ export const metadata: Metadata = { } export default function CreatePollPage() { + async function onSubmitAction(formData: FormData) { + "use server" + await createPoll(formData) + } return (
@@ -17,7 +22,7 @@ export default function CreatePollPage() {

- +
) diff --git a/app/polls/layout.tsx b/app/polls/layout.tsx new file mode 100644 index 0000000..b0cba4e --- /dev/null +++ b/app/polls/layout.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { MainLayout } from "@/components/layout/main-layout"; +import { MinimalAuthProvider } from "@/contexts/auth-context-minimal"; +import { useAuth as useFullAuth } from "@/contexts/auth-context"; +import { LoadingScreen } from "@/components/ui/loading-screen"; +import { createClient } from "@/lib/supabase"; + +interface PollsLayoutProps { + children: React.ReactNode; +} + +export default function PollsLayout({ children }: PollsLayoutProps) { + const { user, loading, signOut } = useFullAuth(); + const supabase = createClient(); + + const handleSignOut = async () => { + await supabase.auth.signOut(); + }; + + if (loading) { + return ; + } + + return ( + + {children} + + ); +} diff --git a/app/polls/page.tsx b/app/polls/page.tsx index b264e27..567247d 100644 --- a/app/polls/page.tsx +++ b/app/polls/page.tsx @@ -1,85 +1,140 @@ -import { Metadata } from "next" -import { Suspense } from "react" -import { PollsGrid } from "@/components/polls/polls-grid" -import { PollsFilters } from "@/components/polls/polls-filters" -import { CreatePollButton } from "@/components/polls/create-poll-button" -import { UserProfile } from "@/components/auth/user-profile" -import { ProtectedRoute } from "@/components/auth/protected-route" -import { EmailVerificationBanner } from "@/components/auth/email-verification-banner" -import { SuccessMessage } from "@/components/ui/success-message" -import { BarChart3, TrendingUp, Users } from "lucide-react" +import { Metadata } from "next"; +import { Suspense } from "react"; +import { PollsGrid } from "@/components/polls/polls-grid"; +import { PollsFilters } from "@/components/polls/polls-filters"; +import { CreatePollButton } from "@/components/polls/create-poll-button"; +import { ProtectedRoute } from "@/components/auth/protected-route"; +import { EmailVerificationBanner } from "@/components/auth/email-verification-banner"; +import { SuccessMessage } from "@/components/ui/success-message"; +import { DashboardContainer } from "@/components/layout/dashboard-shell"; +import { DashboardHeader } from "@/components/layout/dashboard-header"; +import { PollCardSkeleton } from "@/components/ui/loading-states"; +import { BarChart3, TrendingUp, Users, Filter } from "lucide-react"; +import { getPollsOptimized } from "@/lib/data/optimized-polls-server"; export const metadata: Metadata = { - title: "Polls | Polling App", + title: "Polls", description: "Browse and vote on polls created by the community", -} +}; + +export default async function PollsPage() { + // Fetch polls data server-side with caching + const polls = await getPollsOptimized().catch(() => []); + + // Calculate stats from real data + const totalPolls = Array.isArray(polls) ? polls.length : 0; + const activePolls = Array.isArray(polls) ? polls.filter(poll => poll.status === 'active').length : 0; + const totalVotes = Array.isArray(polls) ? polls.reduce((sum, poll) => sum + poll.total_votes, 0) : 0; -export default function PollsPage() { return ( -
-
- - - {/* Success Message */} - - - - - {/* Header Section */} -
- {/* Background decoration */} -
- -
-
-
-
-
- -
-
-

- Polls Dashboard -

-

- Discover and participate in polls from the community -

-
-
- - {/* Stats */} -
-
- - Active Polls -
-
- - Community -
-
-
- -
- - -
+ + + + {/* Success Message */} + + + + + {/* Header Section */} + + + + + {/* Stats Overview */} +
+
+
+
+ +
+
+

+ Total Polls +

+

{totalPolls}

- - {/* Filters Section */} -
- + +
+
+
+ +
+
+

+ Active Polls +

+

{activePolls}

+
+
- - {/* Polls Grid */} -
- + +
+
+
+ +
+
+

+ Total Votes +

+

{totalVotes.toLocaleString()}

+
+
+
+
+ + {/* Filters Section */} +
+
+
+ +

Filter Polls

+
+
+
+ +
+
+
+
+ } + > + + +
+
+ + {/* Polls Grid */} +
+
+

All Polls

+

+ Browse and vote on polls from the community +

+
+
+ + {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ } + > + +
-
+ - ) + ); } diff --git a/components/admin/__tests__/user-management.integration.test.tsx b/components/admin/__tests__/user-management.integration.test.tsx new file mode 100644 index 0000000..f81d548 --- /dev/null +++ b/components/admin/__tests__/user-management.integration.test.tsx @@ -0,0 +1,181 @@ +import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react' +import { UserManagement } from '../user-management' +import { RoleContext } from '@/contexts/role-context' +import { UserRole, RolePermissions } from '@/lib/types/roles' +import { getUsersWithRoles, updateUserRole } from '@/lib/auth/role-actions' +import '@testing-library/jest-dom' + +jest.mock('@/lib/auth/role-actions') + +describe('UserManagement Component Integration', () => { + const defaultPermissions: RolePermissions = { + canCreatePolls: true, + canDeletePolls: true, + canManageUsers: true, + canModerateComments: true, + canAccessAdminPanel: true + } + + const mockRoleContextValue = { + userRole: UserRole.ADMIN, + permissions: defaultPermissions, + isAdmin: true, + isModerator: false, + hasPermission: jest.fn().mockReturnValue(true), + updateRole: jest.fn() + } + + const mockUsers = [ + { id: '1', name: 'User One', email: 'user1@example.com', role: UserRole.USER }, + { id: '2', name: 'Admin One', email: 'admin1@example.com', role: UserRole.ADMIN }, + { id: '3', name: 'Mod One', email: 'mod1@example.com', role: UserRole.MODERATOR } + ] + + const renderWithContext = (component: React.ReactElement) => { + return render( + + {component} + + ) + } + + beforeEach(() => { + jest.clearAllMocks() + ;(getUsersWithRoles as jest.Mock).mockResolvedValue(mockUsers) + }) + + it('should restrict access for non-admin users', async () => { + const nonAdminContext = { + ...mockRoleContextValue, + userRole: UserRole.USER, + isAdmin: false, + permissions: { ...defaultPermissions, canManageUsers: false } + } + + render( + + + + ) + + expect(await screen.findByText(/access denied/i)).toBeInTheDocument() + }) + + it('should handle role updates with confirmation', async () => { + renderWithContext() + + // Wait for users to load + await waitFor(() => { + expect(screen.getByText('User One')).toBeInTheDocument() + }) + + await act(async () => { + // Attempt to change user role to admin + const userRow = await screen.findByTestId('user-row-1') + const roleSelect = within(userRow).getByRole('combobox') + fireEvent.click(roleSelect) + const adminOption = await screen.findByRole('option', { name: /admin/i }) + fireEvent.click(adminOption) + }) + + // Check for confirmation dialog + await waitFor(async () => { + expect(await screen.findByRole('dialog')).toBeInTheDocument() + expect(await screen.findByText(/are you sure/i)).toBeInTheDocument() + }) + + // Confirm the change + const confirmButton = await screen.findByRole('button', { name: /confirm/i }) + fireEvent.click(confirmButton) + + await waitFor(() => { + expect(updateUserRole).toHaveBeenCalledWith('1', UserRole.ADMIN) + expect(getUsersWithRoles).toHaveBeenCalledTimes(2) + }) + }) + + it('should prevent self-role modification', async () => { + const currentUserId = '2' // Admin One's ID + renderWithContext() + + await waitFor(() => { + expect(screen.getByText('Admin One')).toBeInTheDocument() + }) + + // Try to modify admin's own role + const adminRow = await screen.findByTestId('user-row-2') + const adminRoleSelect = within(adminRow).getByRole('combobox') + expect(adminRoleSelect).toBeDisabled() + }) + + it('should filter users by role', async () => { + renderWithContext() + + await waitFor(() => { + expect(screen.getByText('User One')).toBeInTheDocument() + }) + + await act(async () => { + // Use role filter + const filterSelect = await screen.findByRole('combobox', { name: /filter by role/i }) + fireEvent.change(filterSelect, { target: { value: UserRole.ADMIN } }) + }) + + // Should only show admin users + await waitFor(() => { + expect(screen.queryByText('User One')).not.toBeInTheDocument() + expect(screen.getByText('Admin One')).toBeInTheDocument() + expect(screen.queryByText('Mod One')).not.toBeInTheDocument() + }) + }) + + it('should handle errors gracefully', async () => { + const error = new Error('Failed to fetch users') + ;(getUsersWithRoles as jest.Mock).mockRejectedValueOnce(error) + + renderWithContext() + + await waitFor(async () => { + expect(await screen.findByText(/failed to fetch users/i)).toBeInTheDocument() + }) + + // Should show retry button + const retryButton = await screen.findByRole('button', { name: /retry/i }) + ;(getUsersWithRoles as jest.Mock).mockResolvedValueOnce(mockUsers) + fireEvent.click(retryButton) + + await waitFor(() => { + expect(screen.getByText('User One')).toBeInTheDocument() + }) + }) + + it('should sort users by name and role', async () => { + renderWithContext() + + await waitFor(() => { + expect(screen.getByText('User One')).toBeInTheDocument() + }) + + await act(async () => { + // Toggle name sort + const nameSortButton = await screen.findByRole('button', { name: /sort by name/i }) + fireEvent.click(nameSortButton) + }) + + let userNames = await screen.findAllByTestId('user-name') + expect(userNames[0]).toHaveTextContent('Admin One') + expect(userNames[1]).toHaveTextContent('Mod One') + expect(userNames[2]).toHaveTextContent('User One') + + await act(async () => { + // Toggle role sort + const roleSortButton = await screen.findByRole('button', { name: /sort by role/i }) + fireEvent.click(roleSortButton) + }) + + const roles = await screen.findAllByTestId('user-role') + expect(roles[0]).toHaveTextContent('Admin') + expect(roles[1]).toHaveTextContent('Moderator') + expect(roles[2]).toHaveTextContent('User') + }) +}) \ No newline at end of file diff --git a/components/admin/__tests__/user-management.test.tsx b/components/admin/__tests__/user-management.test.tsx new file mode 100644 index 0000000..a38351e --- /dev/null +++ b/components/admin/__tests__/user-management.test.tsx @@ -0,0 +1,253 @@ +import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react' +import { UserManagement } from '../user-management' +import { getUsersWithRoles, updateUserRole } from '@/lib/auth/role-actions' +import { UserRole } from '@/lib/types/roles' +import { RoleContext } from '@/contexts/role-context' +import '@testing-library/jest-dom' + +// Mock the role actions +jest.mock('@/lib/auth/role-actions', () => ({ + getUsersWithRoles: jest.fn(), + updateUserRole: jest.fn() +})) + +const mockRoleContextValue = { + userRole: UserRole.ADMIN, + permissions: { + canCreatePolls: true, + canDeletePolls: true, + canManageUsers: true, + canModerateComments: true, + canAccessAdminPanel: true + }, + isAdmin: true, + isModerator: false, + hasPermission: jest.fn().mockReturnValue(true), + updateRole: jest.fn() +} + +const renderWithRoleContext = (ui: React.ReactElement) => { + return render( + + {ui} + + ) +} + +describe('UserManagement', () => { + const mockUsers = [ + { + id: '1', + name: 'Test User', + email: 'test@example.com', + role: UserRole.USER + }, + { + id: '2', + name: 'Admin User', + email: 'admin@example.com', + role: UserRole.ADMIN + } + ] + + beforeEach(() => { + jest.clearAllMocks() + // Setup default mock implementation + ;(getUsersWithRoles as jest.Mock).mockResolvedValue(mockUsers) + }) + + it('should render user list', async () => { + renderWithRoleContext() + + // Wait for users to load + await screen.findByTestId('user-row-1') + expect(screen.getByText('Test User')).toBeInTheDocument() + expect(screen.getByText('Admin User')).toBeInTheDocument() + }) + + it('should handle role update', async () => { + ;(updateUserRole as jest.Mock).mockResolvedValue(undefined) + + renderWithRoleContext() + + await waitFor(() => { + expect(screen.getByText('Test User')).toBeInTheDocument() + }) + + await act(async () => { + const userRow = await screen.findByTestId('user-row-1') + const roleSelect = within(userRow).getByRole('combobox') + fireEvent.click(roleSelect) + + const moderatorOption = await screen.findByRole('option', { name: /moderator/i }) + fireEvent.click(moderatorOption) + }) + + await waitFor(() => { + expect(updateUserRole).toHaveBeenCalledWith('1', UserRole.MODERATOR) + }) + + await waitFor(() => { + expect(getUsersWithRoles).toHaveBeenCalledTimes(2) // Initial load + after update + }) + }) + + it('should handle error states', async () => { + const errorMessage = 'Failed to load users' + ;(getUsersWithRoles as jest.Mock).mockRejectedValue(new Error(errorMessage)) + + renderWithRoleContext() + + await waitFor(async () => { + expect(await screen.findByText(errorMessage)).toBeInTheDocument() + }) + }) + + it('should handle loading states', async () => { + // Delay the mock response to test loading state + ;(getUsersWithRoles as jest.Mock).mockImplementation( + () => new Promise(resolve => setTimeout(() => resolve(mockUsers), 100)) + ) + + renderWithRoleContext() + + // Check loading state + expect(await screen.findByRole('status')).toBeInTheDocument() + + // Wait for content to load + await waitFor(() => { + expect(screen.getByText('Test User')).toBeInTheDocument() + }) + }) + + it('should disable role select for own user', async () => { + const currentUserMock = [...mockUsers, { + id: 'current-user', + name: 'Current Admin', + email: 'current@example.com', + role: UserRole.ADMIN + }] + ;(getUsersWithRoles as jest.Mock).mockResolvedValue(currentUserMock) + mockRoleContextValue.userRole = UserRole.ADMIN + + renderWithRoleContext() + + await waitFor(async () => { + const currentUserRow = await screen.findByTestId('user-row-current-user') + const roleSelect = within(currentUserRow).getByRole('combobox') + expect(roleSelect).toBeDisabled() + }) + }) + + it('should show confirmation dialog for admin role changes', async () => { + renderWithRoleContext() + + await waitFor(() => { + expect(screen.getByText('Test User')).toBeInTheDocument() + }) + + await act(async () => { + // Find and click the role select + const userRow = await screen.findByTestId('user-row-1') + const roleSelect = within(userRow).getByRole('combobox') + fireEvent.click(roleSelect) + + // Select admin role + const adminOption = await screen.findByRole('option', { name: /admin/i }) + fireEvent.click(adminOption) + }) + + // Check for confirmation dialog + await waitFor(async () => { + expect(await screen.findByRole('dialog')).toBeInTheDocument() + expect(await screen.findByText(/are you sure/i)).toBeInTheDocument() + }) + + // Confirm the change + const confirmButton = await screen.findByRole('button', { name: /confirm/i }) + fireEvent.click(confirmButton) + + await waitFor(() => { + expect(updateUserRole).toHaveBeenCalledWith('1', UserRole.ADMIN) + }) + }) + + it('should handle role update errors gracefully', async () => { + const errorMessage = 'Failed to update role' + ;(updateUserRole as jest.Mock).mockRejectedValue(new Error(errorMessage)) + + renderWithRoleContext() + + await waitFor(() => { + expect(screen.getByText('Test User')).toBeInTheDocument() + }) + + await act(async () => { + // Find and click the role select + const userRow = await screen.findByTestId('user-row-1') + const roleSelect = within(userRow).getByRole('combobox') + fireEvent.click(roleSelect) + + // Select moderator role + const moderatorOption = await screen.findByRole('option', { name: /moderator/i }) + fireEvent.click(moderatorOption) + }) + + await waitFor(async () => { + expect(await screen.findByText(errorMessage)).toBeInTheDocument() + }) + }) + + it('should filter users by role', async () => { + renderWithRoleContext() + + await waitFor(() => { + expect(screen.getByText('Test User')).toBeInTheDocument() + }) + + await act(async () => { + // Find and use the role filter + const filterSelect = await screen.findByRole('combobox', { name: /filter by role/i }) + fireEvent.click(filterSelect) + + // Select admin filter + const adminOption = await screen.findByRole('option', { name: /admin/i }) + fireEvent.click(adminOption) + }) + + // Should only show admin user + await waitFor(() => { + expect(screen.queryByTestId('user-row-1')).not.toBeInTheDocument() + expect(screen.getByTestId('user-row-2')).toBeInTheDocument() + }) + }) + + it('should sort users by name', async () => { + const unsortedUsers = [ + { ...mockUsers[1] }, // Admin User + { ...mockUsers[0] }, // Test User + ] + ;(getUsersWithRoles as jest.Mock).mockResolvedValue(unsortedUsers) + + renderWithRoleContext() + + await waitFor(async () => { + const userRows = await screen.findAllByTestId(/user-row-/) + expect(userRows[0]).toHaveTextContent('Admin User') + expect(userRows[1]).toHaveTextContent('Test User') + }) + + await act(async () => { + // Click sort button + const sortButton = await screen.findByRole('button', { name: /sort by name/i }) + fireEvent.click(sortButton) + }) + + // Check reversed order + await waitFor(async () => { + const userRows = await screen.findAllByTestId(/user-row-/) + expect(userRows[0]).toHaveTextContent('Test User') + expect(userRows[1]).toHaveTextContent('Admin User') + }) + }) +}) \ No newline at end of file diff --git a/components/admin/admin-dashboard.tsx b/components/admin/admin-dashboard.tsx new file mode 100644 index 0000000..648478f --- /dev/null +++ b/components/admin/admin-dashboard.tsx @@ -0,0 +1,81 @@ +"use client"; + +import React from "react"; +import { useRole } from "@/contexts/role-context"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { UserManagement } from "./user-management"; +import { RoleManagement } from "./role-management"; + +export function AdminDashboard() { + const { hasPermission, userRole, permissions, isAdmin, isModerator } = + useRole(); + + // Debug information + console.log("AdminDashboard Debug:", { + userRole, + permissions, + isAdmin, + isModerator, + hasAdminPanelAccess: hasPermission("canAccessAdminPanel"), + }); + + if (!hasPermission("canAccessAdminPanel")) { + return ( +
+

+ You don't have permission to access this page. +

+
+

Debug Info:

+

Role: {userRole}

+

Is Admin: {isAdmin ? "Yes" : "No"}

+

Is Moderator: {isModerator ? "Yes" : "No"}

+

+ Can Access Admin Panel:{" "} + {hasPermission("canAccessAdminPanel") ? "Yes" : "No"} +

+

Permissions: {JSON.stringify(permissions, null, 2)}

+
+
+ ); + } + + return ( +
+

Admin Dashboard

+
+ Debug: User Role: {userRole}, Admin Access: ✅ +
+ + + + User Management + Role Management + + + + + + User Management + + + + + + + + + + + Role Management + + + + + + + +
+ ); +} diff --git a/components/admin/role-management.tsx b/components/admin/role-management.tsx new file mode 100644 index 0000000..9fce068 --- /dev/null +++ b/components/admin/role-management.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { UserRole, RolePermissions, DEFAULT_ROLE_PERMISSIONS } from '@/lib/types/roles'; +import { Switch } from '@/components/ui/switch'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; + +export function RoleManagement() { + const [rolePermissions, setRolePermissions] = React.useState(DEFAULT_ROLE_PERMISSIONS); + + const handlePermissionChange = (role: UserRole, permission: keyof RolePermissions) => { + setRolePermissions((prev) => ({ + ...prev, + [role]: { + ...prev[role], + [permission]: !prev[role][permission], + }, + })); + }; + + return ( +
+ {(Object.keys(rolePermissions) as UserRole[]).map((role) => ( + + + {role} + + +
+ {Object.entries(rolePermissions[role]).map(([permission, enabled]) => ( +
+ + handlePermissionChange(role, permission as keyof RolePermissions)} + disabled={role === 'admin'} // Admin permissions cannot be modified + /> +
+ ))} +
+
+
+ ))} +
+ ); +} \ No newline at end of file diff --git a/components/admin/user-management.tsx b/components/admin/user-management.tsx new file mode 100644 index 0000000..3460d41 --- /dev/null +++ b/components/admin/user-management.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { UserRole } from '@/lib/types/roles'; +import { getUsersWithRoles, updateUserRole } from '@/lib/auth/role-actions'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Spinner } from '@/components/ui/spinner'; + +interface User { + id: string; + email: string; + name: string; + role: UserRole; +} + +const UserRow = ({ + user, + onRoleChange, + disabled +}: { + user: User; + onRoleChange: (id: string, role: UserRole) => void; + disabled: boolean; +}) => ( + + {user.name} + {user.email} + + + + + {/* Add additional actions here if needed */} + + +); + +export function UserManagement() { + const [users, setUsers] = React.useState([]); + const [isLoading, setIsLoading] = React.useState(false); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + loadUsers(); + }, []); + + async function loadUsers() { + try { + setIsLoading(true); + const users = await getUsersWithRoles(); + setUsers(users); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load users'); + } finally { + setIsLoading(false); + } + } + + const handleRoleChange = async (userId: string, newRole: UserRole) => { + try { + setIsLoading(true); + await updateUserRole(userId, newRole); + await loadUsers(); // Reload users to get updated data + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update user role'); + } finally { + setIsLoading(false); + } + }; + + if (error) { + return ( + + {error} + + ); + } + + if (isLoading && users.length === 0) { + return ( +
+ +
+ ); + } + + return ( +
+ + + + Name + Email + Role + Actions + + + + {users.length === 0 ? ( + + + No users found. + + + ) : ( + users.map((user) => ( + + )) + )} + +
+
+ ); +} \ No newline at end of file diff --git a/components/admin/user-management.tsx.bak b/components/admin/user-management.tsx.bak new file mode 100644 index 0000000..a309825 --- /dev/null +++ b/components/admin/user-management.tsx.bak @@ -0,0 +1,100 @@ +import React, { JSX } from 'react'; +import { UserRole } from '@/lib/types/roles'; +import { getUsersWithRoles, updateUserRole } from '@/lib/auth/role-actions'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; + +interface User { + id: string; + email: string; + name: string; + role: UserRole; +} + +export function UserManagement(): JSX.Element { + const [users, setUsers] = React.useState([]); + const [isLoading, setIsLoading] = React.useState(false); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + loadUsers(); + }, []); + + async function loadUsers() { + try { + setIsLoading(true); + const users = await getUsersWithRoles(); + setUsers(users); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load users'); + } finally { + setIsLoading(false); + } + } + + const handleRoleChange = async (userId: string, newRole: UserRole) => { + try { + setIsLoading(true); + await updateUserRole(userId, newRole); + await loadUsers(); // Reload users to get updated data + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update user role'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ {error && ( +
+ {error} +
+ )} + {isLoading && ( +
+
+
+ )} +
+ + + + Name + Email + Role + Actions + + + + {users.map((user) => ( + + {user.name} + {user.email} + + + + + {/* Add additional actions here */} + + + ))} + +
+
+ ); +} \ No newline at end of file diff --git a/components/auth/auth-guard.tsx b/components/auth/auth-guard.tsx new file mode 100644 index 0000000..21c74d1 --- /dev/null +++ b/components/auth/auth-guard.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useAuth } from "@/contexts/auth-context"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { Icons } from "@/components/ui/icons"; + +interface AuthGuardProps { + children: React.ReactNode; + redirectTo?: string; + requireAuth?: boolean; +} + +/** + * AuthGuard component that handles client-side authentication checks + * and redirects users appropriately to prevent auth page access when already logged in + */ +export function AuthGuard({ + children, + redirectTo = "/polls", + requireAuth = false +}: AuthGuardProps) { + const { user, isInitializing } = useAuth(); + const router = useRouter(); + + useEffect(() => { + // Don't do anything while still initializing + if (isInitializing) return; + + // If user is authenticated and on auth pages, redirect away + if (user && !requireAuth) { + router.replace(redirectTo); + return; + } + + // If user is not authenticated and auth is required, redirect to login + if (!user && requireAuth) { + router.replace("/login"); + return; + } + }, [user, isInitializing, requireAuth, redirectTo, router]); + + // Show loading while initializing or while redirecting + if (isInitializing || (user && !requireAuth) || (!user && requireAuth)) { + return ( +
+
+ +

+ {isInitializing ? "Loading..." : "Redirecting..."} +

+
+
+ ); + } + + return <>{children}; +} \ No newline at end of file diff --git a/components/auth/auth-layout.tsx b/components/auth/auth-layout.tsx new file mode 100644 index 0000000..f9ba6ad --- /dev/null +++ b/components/auth/auth-layout.tsx @@ -0,0 +1,138 @@ +import React from 'react' +import Link from 'next/link' +import { BarChart3 } from 'lucide-react' + +interface AuthLayoutProps { + children: React.ReactNode + backgroundType: 'login' | 'register' + sidePanel: React.ReactNode +} + +export function AuthLayout({ children, backgroundType, sidePanel }: AuthLayoutProps) { + // Using consistent blue/indigo background for both login and register pages + const gradients = { + login: 'from-slate-900 via-blue-900 to-indigo-900', + register: 'from-slate-900 via-blue-900 to-indigo-900', + } + + const logoGradients = { + login: 'from-blue-500 to-indigo-600', + register: 'from-blue-500 to-indigo-600', + } + + const patterns = { + login: "data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.03'%3E%3Cpath d='M30 30c0-16.569 13.431-30 30-30v60c-16.569 0-30-13.431-30-30z'%3E%3C/path%3E%3C/g%3E%3C/g%3E%3C/svg%3E", + register: "data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.03'%3E%3Cpath d='M30 30c0-16.569 13.431-30 30-30v60c-16.569 0-30-13.431-30-30z'%3E%3C/path%3E%3C/g%3E%3C/g%3E%3C/svg%3E", + } + + return ( +
+ {/* Two-column layout for large screens, single column for medium/small */} +
+ {/* Left Panel - Only visible on large screens */} +
+ {/* Enhanced multi-layered gradient background */} +
+
+
+
+ + {/* Enhanced animated background elements */} +
+
+
+
+
+ + {/* Subtle grid overlay */} +
+ + {/* Header */} +
+
+ + Polling App +
+
+ + {/* Main content - properly centered */} +
+ {sidePanel} +
+
+ + {/* Right Panel for Large Screens - Form with Enhanced Background */} +
+ {/* Beautiful layered background */} +
+
+ + {/* Animated gradient orbs */} +
+
+
+ + {/* Elegant geometric patterns */} +
+ + {/* Mesh gradient overlay */} +
+ + {/* Subtle dot pattern overlay */} +
+ + {/* Form container for large screens */} +
+ {/* Enhanced glassmorphism backdrop */} +
+ {/* Additional contrast layer */} +
+
+ {children} +
+
+
+
+ + {/* Single-column layout for medium and small screens */} +
+ {/* Centered form container with minimal background */} +
+
+ {children} +
+
+
+
+ ) +} \ No newline at end of file diff --git a/components/auth/email-verification-banner-client.tsx b/components/auth/email-verification-banner-client.tsx new file mode 100644 index 0000000..00bdf94 --- /dev/null +++ b/components/auth/email-verification-banner-client.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { AlertCircle, Mail, X, CheckCircle } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import Link from "next/link"; +import { resendVerificationAction } from "@/lib/auth/actions"; +import { User } from "@supabase/supabase-js"; + +interface EmailVerificationBannerClientProps { + user: User; +} + +export function EmailVerificationBannerClient({ + user, +}: EmailVerificationBannerClientProps) { + const [isDismissed, setIsDismissed] = useState(false); + const [isResending, setIsResending] = useState(false); + + if (isDismissed) { + return null; + } + + const handleResendVerification = async () => { + if (!user.email) return; + + setIsResending(true); + try { + const formData = new FormData(); + formData.append("email", user.email); + + await resendVerificationAction(formData); + toast.success("Verification email sent successfully!"); + } catch (error) { + console.error("Resend verification error:", error); + toast.error("Failed to send verification email. Please try again."); + } finally { + setIsResending(false); + } + }; + + return ( +
+ {/* Background pattern */} +
+ +
+
+
+ +
+
+ +
+
+
+

+ Verify your email address +

+

+ Please verify your email address to unlock all features and ensure your account security. + Check your inbox for a verification link from us. +

+
+ +
+ +
+ + + + Verify email page + +
+
+
+ + {/* Decorative elements */} +
+
+
+ ); +} diff --git a/components/auth/login-form.tsx b/components/auth/login-form.tsx index 8f9765c..dd32c1b 100644 --- a/components/auth/login-form.tsx +++ b/components/auth/login-form.tsx @@ -1,83 +1,102 @@ -"use client" +"use client"; /** * Login Form Component - * + * * Provides user authentication interface with: * - Email and password input fields with validation * - Loading states and error handling * - Email verification reminders * - Responsive design with modern UI * - Integration with Supabase authentication + * - Client-side cache invalidation after login */ -import * as React from "react" -import Link from "next/link" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" -import { Icons } from "@/components/ui/icons" -import { toast } from "sonner" -import { AlertCircle, Mail, Lock, ArrowRight } from "lucide-react" -import { signInAction } from "@/lib/auth/actions" +import * as React from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Icons } from "@/components/ui/icons"; +import { toast } from "sonner"; +import { AlertCircle, Mail, Lock, ArrowRight } from "lucide-react"; +import { useAuth } from "@/contexts/auth-context"; interface LoginFormProps extends React.ComponentProps {} export function LoginForm({ className, ...props }: LoginFormProps) { - // Loading state to prevent multiple submissions and show feedback - const [isLoading, setIsLoading] = React.useState(false) + const router = useRouter(); + const { signIn, loading } = useAuth(); + const [isLoading, setIsLoading] = React.useState(false); /** * Handles form submission for user login - * - * @param formData - Form data containing email and password - * + * + * @param event - Form submission event + * * Features: - * - Calls server action for authentication + * - Uses auth context for better state management + * - Handles client-side authentication * - Provides specific error messages for common issues - * - Handles loading states and user feedback - * - Redirects on successful login (handled by server action) + * - Lets middleware handle redirects to prevent conflicts */ - async function handleSubmit(formData: FormData) { - setIsLoading(true) - + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setIsLoading(true); + try { - await signInAction(formData) - toast.success("Signed in successfully") - // Redirect handled by server action - } catch (error) { - console.error("Sign in error:", error) - const errorMessage = error instanceof Error ? error.message : "Failed to sign in" + const formData = new FormData(event.currentTarget); + const email = formData.get("email") as string; + const password = formData.get("password") as string; + + if (!email || !password) { + toast.error("Please fill in all fields."); + return; + } + + const { error } = await signIn(email, password); - // Provide user-friendly error messages for common scenarios - if (errorMessage.includes('Email not confirmed')) { - toast.error("Please verify your email address before signing in. Check your inbox for a verification link.") - } else if (errorMessage.includes('Invalid login credentials')) { - toast.error("Invalid email or password. Please try again.") + if (error) { + // Provide user-friendly error messages for common scenarios + if (error.message?.includes("Email not confirmed")) { + toast.error( + "Please verify your email address before signing in. Check your inbox for a verification link.", + ); + } else if (error.message?.includes("Invalid login credentials")) { + toast.error("Invalid email or password. Please try again."); + } else { + toast.error(error.message || "Failed to sign in"); + } } else { - toast.error(errorMessage) + toast.success("Signed in successfully"); + // Let the auth context and middleware handle the redirect + router.push("/polls"); } - setIsLoading(false) + } catch (error) { + console.error("Sign in error:", error); + toast.error("An unexpected error occurred. Please try again."); + } finally { + setIsLoading(false); } } return ( - - -
- -
- - Welcome back - - - Enter your credentials to access your account - -
-
- -
+ + + +
@@ -88,50 +107,48 @@ export function LoginForm({ className, ...props }: LoginFormProps) { name="email" type="email" placeholder="Enter your email" - className="pl-10 h-11 border-2 transition-all duration-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20" + className="pl-10 h-10 sm:h-11 border-2 transition-all duration-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 text-base sm:text-sm" required />
- -
+ +
-
- - {/* Email verification reminder */} -
- -
-

Email verification required

-

Make sure to verify your email address before signing in.

- + +
+ Email verification required. + - Need to verify your email? - + Verify here
- - @@ -120,21 +116,10 @@ export function RegisterForm({ className, ...props }: RegisterFormProps) { } return ( - - -
- -
- - Create an account - - - Enter your information below to create your account - -
+
- -
+ +
@@ -145,14 +130,14 @@ export function RegisterForm({ className, ...props }: RegisterFormProps) { name="fullName" type="text" placeholder="Enter your full name" - className="pl-10 h-11 border-2 transition-all duration-200 focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20" + className="pl-10 h-10 sm:h-11 border-2 transition-all duration-200 focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20 text-base sm:text-sm" required minLength={2} />
-
+
@@ -163,13 +148,13 @@ export function RegisterForm({ className, ...props }: RegisterFormProps) { name="email" type="email" placeholder="Enter your email" - className="pl-10 h-11 border-2 transition-all duration-200 focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20" + className="pl-10 h-10 sm:h-11 border-2 transition-all duration-200 focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20 text-base sm:text-sm" required />
-
+
@@ -180,14 +165,14 @@ export function RegisterForm({ className, ...props }: RegisterFormProps) { name="password" type="password" placeholder="Create a password" - className="pl-10 h-11 border-2 transition-all duration-200 focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20" + className="pl-10 h-10 sm:h-11 border-2 transition-all duration-200 focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20 text-base sm:text-sm" required minLength={6} />
-
+
@@ -198,16 +183,16 @@ export function RegisterForm({ className, ...props }: RegisterFormProps) { name="confirmPassword" type="password" placeholder="Confirm your password" - className="pl-10 h-11 border-2 transition-all duration-200 focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20" + className="pl-10 h-10 sm:h-11 border-2 transition-all duration-200 focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20 text-base sm:text-sm" required minLength={6} />
- + + +
+
+ ); +} diff --git a/components/layout/dashboard-shell.tsx b/components/layout/dashboard-shell.tsx index 6fd8433..bea7b36 100644 --- a/components/layout/dashboard-shell.tsx +++ b/components/layout/dashboard-shell.tsx @@ -1,13 +1,161 @@ -import { ReactNode } from "react" +"use client"; + +import { ReactNode, Suspense, useEffect } from "react"; +import { DashboardLayout } from "./main-layout"; +import { PageLoading, Skeleton } from "@/components/ui/loading-screen"; +import { MinimalAuthProvider } from "@/contexts/auth-context-minimal"; +import { useAuth as useFullAuth } from "@/contexts/auth-context"; +import { LoadingScreen } from "@/components/ui/loading-screen"; +import { useAuthLoading } from "@/components/providers/smart-loading-provider"; +import { createClient } from "@/lib/supabase"; + +import { DashboardHeader } from "./dashboard-header"; interface DashboardShellProps { - children: ReactNode + children: ReactNode; + loading?: boolean; + heading?: string; + text?: string; +} + +export function DashboardShell({ + children, + loading = false, + heading, + text, +}: DashboardShellProps) { + const { user, loading: authLoading, isInitializing, signOut } = useFullAuth(); + const { showAuthLoading, hideAuthLoading } = useAuthLoading(); + const supabase = createClient(); + + useEffect(() => { + if (isInitializing) { + showAuthLoading("loading"); + } else { + hideAuthLoading(); + } + }, [isInitializing, showAuthLoading, hideAuthLoading]); + + const handleSignOut = async () => { + showAuthLoading("signout"); + try { + await supabase.auth.signOut(); + } finally { + hideAuthLoading(); + } + }; + + if (isInitializing) { + return ( + + ); + } + + return ( + + +
+ {heading && } + }>{children} +
+
+
+ ); } -export function DashboardShell({ children }: DashboardShellProps) { +// Enhanced dashboard shell with custom container +interface DashboardContainerProps { + children: ReactNode; + className?: string; + loading?: boolean; +} + +export function DashboardContainer({ + children, + className = "", + loading = false, +}: DashboardContainerProps) { + if (loading) { + return ( +
+ +
+ ); + } + return ( -
- {children} +
+ }>{children} +
+ ); +} + +// Skeleton loader for dashboard content +function DashboardSkeletonLoader() { + return ( +
+ {/* Header skeleton */} +
+
+ + +
+ +
+ + {/* Stats cards skeleton */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + + +
+ ))} +
+ + {/* Content skeleton */} +
+
+
+
+ + +
+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ + +
+ + +
+
+ ))} +
+
+
+
+
+
+ + +
+
+ {Array.from({ length: 2 }).map((_, i) => ( + + ))} +
+
+
+
- ) + ); } diff --git a/components/layout/main-layout.tsx b/components/layout/main-layout.tsx new file mode 100644 index 0000000..064fc22 --- /dev/null +++ b/components/layout/main-layout.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { ReactNode, Suspense } from "react"; +import { Navbar } from "./navbar"; +import { MobileNav } from "./mobile-nav"; +import { LoadingScreen, PageLoading } from "@/components/ui/loading-screen"; +import { useLoading } from "@/components/providers/smart-loading-provider"; + +interface MainLayoutProps { + children: ReactNode; + loading?: boolean; +} + +export function MainLayout({ children, loading = false }: MainLayoutProps) { + const { isLoading } = useLoading(); + + if (loading || isLoading) { + return ( + + ); + } + + return ( +
+ {/* Top Navigation */} + + + {/* Mobile Navigation (only visible on mobile) */} +
+ +
+ + {/* Main Content */} +
+ }>{children} +
+
+ ); +} + +// Layout variant without navbar for auth pages +interface AuthLayoutProps { + children: ReactNode; +} + +export function AuthLayout({ children }: AuthLayoutProps) { + return ( +
+
{children}
+
+ ); +} + +// Layout with container for dashboard pages +interface DashboardLayoutProps { + children: ReactNode; + loading?: boolean; +} + +export function DashboardLayout({ + children, + loading = false, +}: DashboardLayoutProps) { + const { isLoading } = useLoading(); + + if (loading || isLoading) { + return ( + + ); + } + + return ( +
+ {/* Top Navigation */} + + + {/* Mobile Navigation (only visible on mobile) */} +
+ +
+ + {/* Main Content with Container */} +
+ }>{children} +
+
+ ); +} diff --git a/components/layout/mobile-nav.tsx b/components/layout/mobile-nav.tsx new file mode 100644 index 0000000..7171e8e --- /dev/null +++ b/components/layout/mobile-nav.tsx @@ -0,0 +1,205 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useAuth } from "@/contexts/auth-context-minimal"; +import { Button } from "@/components/ui/button"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; +import { cn } from "@/lib/utils"; +import { + LayoutDashboard, + BarChart3 as Poll, + LogOut, + Shield, + Plus, + Menu, + X, +} from "lucide-react"; +import { useState, useEffect } from "react"; +import { createClient } from "@/lib/supabase"; + +interface NavigationItem { + href: string; + label: string; + icon: React.ComponentType<{ className?: string }>; + requiresRole?: string[]; +} + +const navigationItems: NavigationItem[] = [ + { + href: "/dashboard", + label: "Dashboard", + icon: LayoutDashboard, + }, + { + href: "/polls", + label: "Polls", + icon: Poll, + }, + { + href: "/polls/create", + label: "Create Poll", + icon: Plus, + }, + { + href: "/admin", + label: "Admin", + icon: Shield, + requiresRole: ["admin", "moderator"], + }, +]; + +export function MobileNav() { + const pathname = usePathname(); + const { user, signOut } = useAuth(); + const [userRole, setUserRole] = useState(null); + const [isOpen, setIsOpen] = useState(false); + + // Fetch user role from profiles table + useEffect(() => { + async function fetchUserRole() { + if (!user) return; + + const supabase = createClient(); + const { data: profile, error } = await supabase + .from("profiles") + .select("role") + .eq("id", user.id) + .single(); + + if (!error && profile) { + setUserRole(profile.role); + } + } + + fetchUserRole(); + }, [user]); + + const handleSignOut = async () => { + setIsOpen(false); + await signOut(); + }; + + const getInitials = (name?: string, email?: string) => { + if (name) { + return name + .split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2); + } + if (email) { + return email.slice(0, 2).toUpperCase(); + } + return "U"; + }; + + const getUserDisplayName = () => { + return user?.user_metadata?.full_name || user?.email || "User"; + }; + + return ( + + + + + +
+ {/* Header */} +
+
+ +
+
+

Polling App

+
+
+ + {/* User Profile Section */} + {user && ( +
+
+ + + + {getInitials( + user?.user_metadata?.full_name, + user?.email + )} + + +
+

+ {getUserDisplayName()} +

+

+ {user.email} +

+
+
+
+ )} + + {/* Navigation */} + + + {/* Footer */} +
+ +
+
+
+
+ ); +} diff --git a/components/layout/navbar.tsx b/components/layout/navbar.tsx new file mode 100644 index 0000000..ce17163 --- /dev/null +++ b/components/layout/navbar.tsx @@ -0,0 +1,282 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useAuth } from "@/contexts/auth-context-minimal"; +import { Button } from "@/components/ui/button"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { ThemeToggle } from "@/components/theme/theme-toggle"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; +import { + LayoutDashboard, + BarChart3 as Poll, + LogOut, + Shield, + Plus, + Settings, + User, + ChevronDown, +} from "lucide-react"; +import { useState, useEffect } from "react"; +import { createClient } from "@/lib/supabase"; + +interface NavigationItem { + href: string; + label: string; + icon: React.ComponentType<{ className?: string }>; + requiresRole?: string[]; +} + +const navigationItems: NavigationItem[] = [ + { + href: "/dashboard", + label: "Dashboard", + icon: LayoutDashboard, + }, + { + href: "/polls", + label: "Polls", + icon: Poll, + }, + { + href: "/polls/create", + label: "Create Poll", + icon: Plus, + }, + { + href: "/admin", + label: "Admin", + icon: Shield, + requiresRole: ["admin", "moderator"], + }, +]; + +export function Navbar() { + const pathname = usePathname(); + const { user, signOut } = useAuth(); + const [userRole, setUserRole] = useState(null); + const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); + + // Fetch user role from profiles table + useEffect(() => { + async function fetchUserRole() { + if (!user) return; + + const supabase = createClient(); + const { data: profile, error } = await supabase + .from("profiles") + .select("role") + .eq("id", user.id) + .single(); + + if (!error && profile) { + setUserRole(profile.role); + } + } + + fetchUserRole(); + }, [user]); + + const handleSignOut = async () => { + setIsUserMenuOpen(false); + await signOut(); + }; + + const getInitials = (name?: string, email?: string) => { + if (name) { + return name + .split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2); + } + if (email) { + return email.slice(0, 2).toUpperCase(); + } + return "U"; + }; + + const getUserDisplayName = () => { + return user?.user_metadata?.full_name || user?.email || "User"; + }; + + return ( +
+
+ {/* Logo */} +
+ +
+ +
+ + Polling App + + +
+ + {/* Desktop Navigation */} + + +
+ {/* Mobile Logo */} +
+ +
+ +
+ Polling App + +
+ + {/* Right side: Theme Toggle + User Menu */} +
+ {/* Theme Toggle */} + + + {/* User Menu */} + {user && ( + + + + + + +
+

+ {getUserDisplayName()} +

+

+ {user.email} +

+
+
+ + + setIsUserMenuOpen(false)} + > + + Dashboard + + + + setIsUserMenuOpen(false)} + > + + My Polls + + + + setIsUserMenuOpen(false)} + > + + Create Poll + + + {userRole && ["admin", "moderator"].includes(userRole) && ( + <> + + + setIsUserMenuOpen(false)} + > + + Admin Panel + + + + )} + + + + Sign Out + +
+
+ )} +
+
+
+
+ ); +} diff --git a/components/layout/navigation.tsx b/components/layout/navigation.tsx new file mode 100644 index 0000000..5218d46 --- /dev/null +++ b/components/layout/navigation.tsx @@ -0,0 +1,187 @@ +"use client"; + +// @deprecated This component has been replaced by the new Navbar and MobileNav components +// Please use components/layout/navbar.tsx and components/layout/mobile-nav.tsx instead +// This file will be removed in a future update + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useAuth } from "@/contexts/auth-context-minimal"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { + LayoutDashboard, + BarChart3 as Poll, + LogOut, + Shield, + Plus, + Home, + Menu, + X, +} from "lucide-react"; +import { useState, useEffect } from "react"; +import { createClient } from "@/lib/supabase"; + +interface NavigationItem { + href: string; + label: string; + icon: React.ComponentType<{ className?: string }>; + requiresRole?: string[]; +} + +const navigationItems: NavigationItem[] = [ + { + href: "/dashboard", + label: "Dashboard", + icon: LayoutDashboard, + }, + { + href: "/polls", + label: "Polls", + icon: Poll, + }, + { + href: "/polls/create", + label: "Create Poll", + icon: Plus, + }, + { + href: "/admin", + label: "Admin", + icon: Shield, + requiresRole: ["admin", "moderator"], + }, +]; + +/** + * @deprecated Use Navbar and MobileNav components instead + */ +export function Navigation() { + const pathname = usePathname(); + const { user, signOut } = useAuth(); + const [isOpen, setIsOpen] = useState(false); + const [userRole, setUserRole] = useState(null); + + // Fetch user role from profiles table + useEffect(() => { + async function fetchUserRole() { + if (!user) return; + + const supabase = createClient(); + const { data: profile, error } = await supabase + .from("profiles") + .select("role") + .eq("id", user.id) + .single(); + + if (!error && profile) { + setUserRole(profile.role); + } + } + + fetchUserRole(); + }, [user]); + + const handleSignOut = async () => { + await signOut(); + }; + + return ( + <> + {/* Mobile Menu Button */} +
+ +
+ + {/* Mobile Overlay */} + {isOpen && ( +
setIsOpen(false)} + /> + )} + + {/* Desktop Sidebar & Mobile Slide-out */} +
+ {/* Header */} +
+ setIsOpen(false)} + > +
+ +
+
+

Polling App

+
+ +
+ + {/* Navigation */} + + + {/* User Profile */} +
+ {user && ( +
+ {user.email} +
+ )} + +
+
+ + ); +} diff --git a/components/polls/PollChart.tsx b/components/polls/PollChart.tsx new file mode 100644 index 0000000..f9f6aec --- /dev/null +++ b/components/polls/PollChart.tsx @@ -0,0 +1,79 @@ +"use client" + +import { + PieChart, + Pie, + Cell, + ResponsiveContainer, + Tooltip, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, +} from "recharts" + +type ChartData = { name: string; value: number }[] + +interface PollChartProps { + data: ChartData + type?: "pie" | "bar" +} + +const COLORS = [ + "#3b82f6", + "#10b981", + "#f59e0b", + "#ef4444", + "#8b5cf6", + "#06b6d4", + "#84cc16", +] + +export function PollChart({ data, type = "pie" }: PollChartProps) { + if (type === "bar") { + return ( +
+ + + + 6} interval={0} angle={0} dy={8} /> + + + + {data.map((_, index) => ( + + ))} + + + +
+ ) + } + + return ( +
+ + + + + {data.map((_, index) => ( + + ))} + + + +
+ ) +} + + diff --git a/components/polls/comment-form.tsx b/components/polls/comment-form.tsx new file mode 100644 index 0000000..6ef1013 --- /dev/null +++ b/components/polls/comment-form.tsx @@ -0,0 +1,51 @@ +"use client" + +import { useState } from "react" +import { Card, CardContent } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { toast } from "sonner" + +interface CommentFormProps { + onSubmitAction: (formData: FormData) => Promise +} + +export function CommentForm({ onSubmitAction }: CommentFormProps) { + const [value, setValue] = useState("") + const [submitting, setSubmitting] = useState(false) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (!value.trim()) return + setSubmitting(true) + try { + const fd = new FormData() + fd.append("text", value.trim()) + await onSubmitAction(fd) + setValue("") + toast.success("Comment posted") + } finally { + setSubmitting(false) + } + } + + return ( + + + + setValue(e.target.value)} + placeholder="Add a comment" + aria-label="Add a comment" + /> + + + + + ) +} + + diff --git a/components/polls/comment-list.tsx b/components/polls/comment-list.tsx new file mode 100644 index 0000000..2fd8b31 --- /dev/null +++ b/components/polls/comment-list.tsx @@ -0,0 +1,32 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { getComments } from "@/lib/polls/comments" + +export async function CommentList({ pollId }: { pollId: string }) { + const comments = await getComments(pollId).catch(() => []) + return ( + + + Comments + What people are saying + + + {comments.length === 0 ? ( +

No comments yet.

+ ) : ( +
    + {comments.map((c: any) => ( +
  • +
    {c.text}
    +
    + {new Date(c.created_at).toLocaleString()} +
    +
  • + ))} +
+ )} +
+
+ ) +} + + diff --git a/components/polls/create-poll-form.tsx b/components/polls/create-poll-form.tsx index 9769607..e9dfc30 100644 --- a/components/polls/create-poll-form.tsx +++ b/components/polls/create-poll-form.tsx @@ -20,10 +20,14 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Switch } from "@/components/ui/switch" import { Icons } from "@/components/ui/icons" import { Plus, X } from "lucide-react" -import { createPoll } from "@/lib/polls/actions" +// Server action will be passed in from the server page // import { toast } from "sonner" -export function CreatePollForm() { +interface CreatePollFormProps { + onSubmitAction: (formData: FormData) => Promise +} + +export function CreatePollForm({ onSubmitAction }: CreatePollFormProps) { // Form state management const [isLoading, setIsLoading] = useState(false) // Loading state during form submission const [options, setOptions] = useState(["", ""]) // Poll options (minimum 2 required) @@ -75,7 +79,7 @@ export function CreatePollForm() { setIsLoading(true) try { - await createPoll(formData) + await onSubmitAction(formData) // Success handled by Server Action redirect to polls page } catch (error) { console.error("Error creating poll:", error) diff --git a/components/polls/delete-poll-button.tsx b/components/polls/delete-poll-button.tsx index efbf31d..1a9cebd 100644 --- a/components/polls/delete-poll-button.tsx +++ b/components/polls/delete-poll-button.tsx @@ -3,7 +3,6 @@ import { useState } from "react" import { Button } from "@/components/ui/button" import { Trash2 } from "lucide-react" -import { deletePoll } from "@/lib/polls/actions" import { AlertDialog, AlertDialogAction, @@ -18,23 +17,12 @@ import { interface DeletePollButtonProps { pollId: string + action: (formData: FormData) => Promise } -export function DeletePollButton({ pollId }: DeletePollButtonProps) { +export function DeletePollButton({ pollId, action }: DeletePollButtonProps) { const [isDeleting, setIsDeleting] = useState(false) - const handleDelete = async () => { - setIsDeleting(true) - try { - await deletePoll(pollId) - // Success handled by Server Action redirect - } catch (error) { - console.error("Error deleting poll:", error) - alert(error instanceof Error ? error.message : "Failed to delete poll") - setIsDeleting(false) - } - } - return ( @@ -53,13 +41,14 @@ export function DeletePollButton({ pollId }: DeletePollButtonProps) { Cancel - - {isDeleting ? "Deleting..." : "Delete Poll"} - +
+ + + + +
diff --git a/components/polls/poll-chat.tsx b/components/polls/poll-chat.tsx new file mode 100644 index 0000000..f0ccf77 --- /dev/null +++ b/components/polls/poll-chat.tsx @@ -0,0 +1,57 @@ +"use client" + +import { useState } from "react" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" + +interface PollChatProps { + pollId: string +} + +export function PollChat({ pollId }: PollChatProps) { + const [messages, setMessages] = useState<{ id: string; text: string }[]>([]) + const [value, setValue] = useState("") + + const onSend = (e: React.FormEvent) => { + e.preventDefault() + if (!value.trim()) return + setMessages((prev) => [...prev, { id: String(Date.now()), text: value.trim() }]) + setValue("") + } + + return ( + + + Discussion + Chat about this poll + + +
+ {messages.length === 0 ? ( +

No messages yet. Be the first to comment.

+ ) : ( +
    + {messages.map((m) => ( +
  • {m.text}
  • + ))} +
+ )} +
+ +
+ setValue(e.target.value)} + placeholder="Write a message..." + className="flex-1" + aria-label="Message input" + /> + +
+
+
+ ) +} + + diff --git a/components/polls/poll-results.tsx b/components/polls/poll-results.tsx index 0ae63e9..e9c1ced 100644 --- a/components/polls/poll-results.tsx +++ b/components/polls/poll-results.tsx @@ -2,6 +2,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Badge } from "@/components/ui/badge" import { Users, TrendingUp, Clock } from "lucide-react" import { Poll } from "@/lib/types/poll" +import { PollChart } from "@/components/polls/PollChart" interface PollResultsProps { poll: Poll @@ -13,9 +14,10 @@ export function PollResults({ poll }: PollResultsProps) { ) const winningPercentage = poll.totalVotes > 0 ? (winningOption.votes / poll.totalVotes) * 100 : 0 + const chartData = poll.options.map(o => ({ name: o.text, value: o.votes })) return ( - + @@ -42,6 +44,13 @@ export function PollResults({ poll }: PollResultsProps) {

+ {/* Chart Visualization */} +
+

Visualization

+ + +
+ {/* Results Breakdown */}

Vote Breakdown

diff --git a/components/polls/poll-view.tsx b/components/polls/poll-view.tsx index 8e8f786..400cb70 100644 --- a/components/polls/poll-view.tsx +++ b/components/polls/poll-view.tsx @@ -20,14 +20,14 @@ import { Checkbox } from "@/components/ui/checkbox" import { Icons } from "@/components/ui/icons" import { Users, Calendar, Clock } from "lucide-react" import { Poll } from "@/lib/types/poll" -import { votePoll } from "@/lib/polls/actions" import { toast } from "sonner" interface PollViewProps { poll: Poll; + submitVote: (formData: FormData) => Promise<{ success: boolean; message: string } | void>; } -export function PollView({ poll }: PollViewProps) { +export function PollView({ poll, submitVote }: PollViewProps) { // State management for voting interface const [selectedOptions, setSelectedOptions] = useState([]); // Track selected option IDs const [hasVoted, setHasVoted] = useState(false); // Toggle between voting and results view @@ -41,23 +41,31 @@ export function PollView({ poll }: PollViewProps) { */ const handleVote = async () => { if (selectedOptions.length === 0) return - - setIsLoading(true); - + setIsLoading(true) try { - // Submit votes using server action - await votePoll(poll.id, selectedOptions) - setHasVoted(true) // Switch to results view - toast.success("Vote submitted successfully!") + const formData = new FormData() + formData.append("pollId", poll.id) + selectedOptions.forEach((id) => formData.append("option", id)) + + const result = await submitVote(formData) + + setHasVoted(true) + + // Show appropriate success message based on server response + if (result && result.message) { + toast.success(result.message) + } else { + toast.success("Vote submitted successfully!") + } + } catch (error) { - console.error("Voting error:", error); - const errorMessage = - error instanceof Error ? error.message : "Failed to submit vote"; - toast.error(errorMessage); + console.error("Voting error:", error) + const errorMessage = error instanceof Error ? error.message : "Failed to submit vote" + toast.error(errorMessage) } finally { - setIsLoading(false); + setIsLoading(false) } - }; + } /** * Handles option selection based on poll voting rules @@ -153,7 +161,7 @@ export function PollView({ poll }: PollViewProps) { // Voting interface - shown before user votes return ( - + {poll.title} @@ -202,7 +210,7 @@ export function PollView({ poll }: PollViewProps) {
) : ( // Single choice interface with radio buttons - + {poll.options.map((option) => (
diff --git a/components/polls/polls-grid.tsx b/components/polls/polls-grid.tsx index 2226a00..15abf78 100644 --- a/components/polls/polls-grid.tsx +++ b/components/polls/polls-grid.tsx @@ -3,7 +3,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Eye, Users, Clock } from "lucide-react" -import { getPolls } from "@/lib/polls/actions" +import { getPollsOptimized } from "@/lib/data/optimized-polls-server" import { PollFilters } from "@/lib/types/poll" interface PollsGridProps { @@ -11,9 +11,9 @@ interface PollsGridProps { } export async function PollsGrid({ filters }: PollsGridProps) { - const polls = await getPolls(filters) + const polls = await getPollsOptimized(filters).catch(() => []); - if (polls.length === 0) { + if (!Array.isArray(polls) || polls.length === 0) { return (
@@ -52,12 +52,12 @@ export async function PollsGrid({ filters }: PollsGridProps) {
- {poll.totalVotes} votes + {poll.total_votes} votes
- {poll.endsAt && ( + {poll.expires_at && (
- Ends {new Date(poll.endsAt).toLocaleDateString()} + Ends {new Date(poll.expires_at).toLocaleDateString()}
)}
diff --git a/components/polls/polls-list.tsx b/components/polls/polls-list.tsx index 3280474..8550246 100644 --- a/components/polls/polls-list.tsx +++ b/components/polls/polls-list.tsx @@ -3,11 +3,18 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Eye, Users, Calendar, Edit, Trash2 } from "lucide-react" -import { getUserPolls } from "@/lib/polls/actions" +import { getPolls } from "@/lib/polls/actions" import { DeletePollButton } from "@/components/polls/delete-poll-button" export async function PollsList() { - const polls = await getUserPolls() + const polls = await getPolls() + + async function deleteAction(formData: FormData) { + "use server" + const pollId = String(formData.get("pollId")) + const { deletePoll } = await import("@/lib/polls/actions") + await deletePoll(pollId) + } if (polls.length === 0) { return ( @@ -69,7 +76,7 @@ export async function PollsList() { Edit - +
diff --git a/components/polls/share-poll.tsx b/components/polls/share-poll.tsx index 01aecbd..b989db1 100644 --- a/components/polls/share-poll.tsx +++ b/components/polls/share-poll.tsx @@ -1,27 +1,39 @@ "use client" -import { useState } from "react" +import { useEffect, useState } from "react" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Badge } from "@/components/ui/badge" import { Share2, Copy, Check, Twitter, Facebook, Linkedin } from "lucide-react" -import { Poll } from "@/lib/types/poll" +import { Poll, ShareStats } from "@/lib/types/poll" import { QRCode } from "@/components/qr/QRCode" interface SharePollProps { poll: Poll + initialShareStats?: ShareStats + recordShare?: (platform?: string) => Promise } -export function SharePoll({ poll }: SharePollProps) { +export function SharePoll({ poll, initialShareStats, recordShare }: SharePollProps) { const [copied, setCopied] = useState(false) - const pollUrl = `${window.location.origin}/polls/${poll.id}` + const [pollUrl, setPollUrl] = useState("") + const [shareStats, setShareStats] = useState({ total: initialShareStats?.total ?? 0, today: initialShareStats?.today ?? 0 }) + + useEffect(() => { + // Compute on client to avoid SSR window reference + const origin = typeof window !== 'undefined' ? window.location.origin : '' + setPollUrl(`${origin}/polls/${poll.id}`) + }, [poll.id]) const handleCopyLink = async () => { try { await navigator.clipboard.writeText(pollUrl) setCopied(true) setTimeout(() => setCopied(false), 2000) + // Record share event for copy action + recordShare?.('copy') + setShareStats((s) => ({ total: s.total + 1, today: s.today + 1 })) } catch (err) { console.error('Failed to copy link:', err) } @@ -45,6 +57,9 @@ export function SharePoll({ poll }: SharePollProps) { if (url) { window.open(url, '_blank', 'width=600,height=400') + // Fire and forget record + recordShare?.(platform) + setShareStats((s) => ({ total: s.total + 1, today: s.today + 1 })) } } @@ -62,14 +77,15 @@ export function SharePoll({ poll }: SharePollProps) { {/* Direct Link */}
- +
- + +
+
+ ) + ); + } + + return this.props.children; + } +} diff --git a/components/providers/smart-loading-provider.tsx b/components/providers/smart-loading-provider.tsx new file mode 100644 index 0000000..c0f3a8a --- /dev/null +++ b/components/providers/smart-loading-provider.tsx @@ -0,0 +1,186 @@ +"use client"; + +import React, { + createContext, + useContext, + useEffect, + useState, + useCallback, + ReactNode, +} from "react"; +import { usePathname } from "next/navigation"; +import { LoadingScreen } from "@/components/ui/loading-screen"; + +interface LoadingContextType { + isLoading: boolean; + message: string; + variant: "default" | "minimal" | "logo"; + showLoading: (message?: string, variant?: "default" | "minimal" | "logo") => void; + hideLoading: () => void; + updateMessage: (message: string) => void; +} + +const LoadingContext = createContext(undefined); + +interface LoadingProviderProps { + children: ReactNode; + defaultMessage?: string; + enableRouteLoading?: boolean; +} + +export function LoadingProvider({ + children, + defaultMessage = "Loading...", + enableRouteLoading = false, // Disabled by default to prevent auth conflicts +}: LoadingProviderProps) { + const [isLoading, setIsLoading] = useState(false); + const [message, setMessage] = useState(defaultMessage); + const [variant, setVariant] = useState<"default" | "minimal" | "logo">("default"); + const [manualLoading, setManualLoading] = useState(false); + const pathname = usePathname(); + + // Handle route changes - but only for manual navigation, not auth redirects + useEffect(() => { + if (!enableRouteLoading || manualLoading) return; + + let timeoutId: NodeJS.Timeout; + let isAuthPath = pathname === '/login' || pathname === '/verify-email' || pathname === '/register'; + + // Don't show loading overlay for auth-related paths or very quick transitions + if (isAuthPath) { + return; + } + + // Show minimal loading for non-auth route changes + setIsLoading(true); + setMessage("Loading..."); + setVariant("minimal"); + + // Very short loading time to avoid interfering with Next.js + timeoutId = setTimeout(() => { + setIsLoading(false); + }, 100); + + return () => { + clearTimeout(timeoutId); + }; + }, [pathname, enableRouteLoading, manualLoading]); + + const showLoading = useCallback( + ( + newMessage?: string, + newVariant: "default" | "minimal" | "logo" = "default" + ) => { + setManualLoading(true); + setIsLoading(true); + setMessage(newMessage || defaultMessage); + setVariant(newVariant); + }, + [defaultMessage] + ); + + const hideLoading = useCallback(() => { + setIsLoading(false); + setManualLoading(false); + }, []); + + const updateMessage = useCallback((newMessage: string) => { + setMessage(newMessage); + }, []); + + // Auto-hide manual loading after a reasonable time to prevent stuck states + useEffect(() => { + if (manualLoading && isLoading) { + const maxTimeout = setTimeout(() => { + setIsLoading(false); + setManualLoading(false); + }, 10000); // 10 second maximum + + return () => clearTimeout(maxTimeout); + } + }, [manualLoading, isLoading]); + + const contextValue: LoadingContextType = { + isLoading, + message, + variant, + showLoading, + hideLoading, + updateMessage, + }; + + return ( + + {children} + + + ); +} + +export function useLoading() { + const context = useContext(LoadingContext); + if (context === undefined) { + throw new Error("useLoading must be used within a LoadingProvider"); + } + return context; +} + +// Hook for auth-related loading states +export function useAuthLoading() { + const { showLoading, hideLoading } = useLoading(); + + const showAuthLoading = useCallback((action: string) => { + const messages = { + signin: "Signing in...", + signup: "Creating your account...", + signout: "Signing out...", + verify: "Verifying your email...", + reset: "Sending reset email...", + loading: "Loading your account...", + }; + + showLoading( + messages[action as keyof typeof messages] || "Authenticating...", + "logo" + ); + }, [showLoading]); + + return { + showAuthLoading, + hideAuthLoading: hideLoading, + }; +} + +// Hook for async operations with loading states +export function useAsyncOperation() { + const { showLoading, hideLoading } = useLoading(); + + const executeAsync = useCallback( + async ( + operation: () => Promise, + options?: { + loadingMessage?: string; + variant?: "default" | "minimal" | "logo"; + } + ): Promise => { + try { + showLoading( + options?.loadingMessage || "Processing...", + options?.variant || "default" + ); + + const result = await operation(); + return result; + } finally { + hideLoading(); + } + }, + [showLoading, hideLoading] + ); + + return { executeAsync }; +} \ No newline at end of file diff --git a/components/qr/QRCode.tsx b/components/qr/QRCode.tsx index 4009aa3..6230355 100644 --- a/components/qr/QRCode.tsx +++ b/components/qr/QRCode.tsx @@ -1,7 +1,7 @@ "use client" import React, { useRef } from "react" -import QRCodeReact from "qrcode.react" +import { QRCodeCanvas } from "qrcode.react" import { Button } from "@/components/ui/button" interface QRCodeProps { @@ -26,7 +26,7 @@ export function QRCode({ value, size = 160, className, fileName = "qr-code.png" return (
- +
diff --git a/components/streaming/dashboard-stats-streaming.tsx b/components/streaming/dashboard-stats-streaming.tsx new file mode 100644 index 0000000..19b55c8 --- /dev/null +++ b/components/streaming/dashboard-stats-streaming.tsx @@ -0,0 +1,88 @@ +import { Suspense } from 'react'; +import { getCachedUserSession, getUserStatsOptimized } from '@/lib/data/optimized-polls-server'; +import { DashboardStatsSkeleton } from '@/components/ui/loading-states'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Users, BarChart3, TrendingUp, Activity } from 'lucide-react'; + +async function DashboardStats() { + const session = await getCachedUserSession(); + + if (!session?.user) { + return ( +
+

Please log in to view your dashboard

+
+ ); + } + + const stats = await getUserStatsOptimized(session.user.id).catch(() => ({ + totalPolls: 0, + activePolls: 0, + totalVotes: 0, + avgVotes: 0 + })) as { totalPolls: number; activePolls: number; totalVotes: number; avgVotes: number }; + + return ( +
+ + + Total Polls + + + +
{stats.totalPolls}
+

+ All time polls created +

+
+
+ + + + Active Polls + + + +
{stats.activePolls}
+

+ Currently running polls +

+
+
+ + + + Total Votes + + + +
{stats.totalVotes}
+

+ Votes across all polls +

+
+
+ + + + Avg Votes + + + +
{stats.avgVotes}
+

+ Votes per poll average +

+
+
+
+ ); +} + +export function DashboardStatsStreaming() { + return ( + }> + + + ); +} diff --git a/components/streaming/polls-streaming.tsx b/components/streaming/polls-streaming.tsx new file mode 100644 index 0000000..837c97d --- /dev/null +++ b/components/streaming/polls-streaming.tsx @@ -0,0 +1,56 @@ +import { Suspense } from 'react'; +import { getPollsOptimized } from '@/lib/data/optimized-polls-server'; +import { PollCardSkeleton } from '@/components/ui/loading-states'; +import { PollView } from '@/components/polls/poll-view'; +import { submitVoteAction } from '@/lib/polls/actions'; + +interface PollsStreamingProps { + filters?: { + category?: string; + status?: string; + search?: string; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; + }; +} + +async function PollsList({ filters }: PollsStreamingProps) { + const polls = await getPollsOptimized(filters).catch(() => []); + + if (!Array.isArray(polls) || polls.length === 0) { + return ( +
+

No polls found

+

+ Try adjusting your filters or create a new poll +

+
+ ); + } + + return ( +
+ {polls.map((poll) => ( + + ))} +
+ ); +} + +export function PollsStreaming({ filters }: PollsStreamingProps) { + return ( + }> + + + ); +} + +function PollsListSkeleton() { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ); +} diff --git a/components/theme/theme-provider.tsx b/components/theme/theme-provider.tsx index 0062deb..4bb3b24 100644 --- a/components/theme/theme-provider.tsx +++ b/components/theme/theme-provider.tsx @@ -1,8 +1,57 @@ -"use client" +"use client"; -import * as React from "react" -import { ThemeProvider as NextThemesProvider } from "next-themes" +import * as React from "react"; +import { ThemeProvider as NextThemesProvider } from "next-themes"; +import { type ThemeProviderProps } from "next-themes"; -export function ThemeProvider({ children, ...props }: any) { - return {children} +export function ThemeProvider({ + children, + attribute = "class", + defaultTheme = "system", + enableSystem = true, + disableTransitionOnChange = false, + storageKey = "theme", + themes = ["light", "dark", "system"], + ...props +}: ThemeProviderProps) { + return ( + + {children} + + ); +} + +// Script to prevent FOUC (Flash of Unstyled Content) +export function ThemeScript() { + return ( +