From f4f595f74d0b7967e06b7cbcdea1de6014d80732 Mon Sep 17 00:00:00 2001 From: Steven Sousa Date: Mon, 12 Jan 2026 20:24:25 -0500 Subject: [PATCH] Refactor and enhance components for improved functionality and styling - Updated SpotifyStatus component to use consistent string quotes and improved error handling. - Introduced MobileMenuToggle component for mobile navigation. - Refactored Navbar component to integrate MobileMenuToggle and streamline layout. - Simplified NavbarLinks component by utilizing a centralized NAV_LINKS constant. - Removed unused navbar.module.css file and replaced animations with Tailwind CSS classes. - Added LoginForm and SignupForm components for user authentication. - Created Card component for consistent card styling across the application. - Updated mode toggle alignment in the UI. - Refactored user authentication logic and improved error handling in actions. - Consolidated asset paths in a new constants file. - Introduced new schemas for contact forms and social media links. - Updated social media links to use centralized asset paths. - Added new types for better type safety across the application. - Fixed import paths for authentication-related modules. - Added missing images and SVG assets for branding. --- app/(auth)/login/page.tsx | 11 ++ app/(auth)/signup/page.tsx | 11 ++ app/api/auth/[...all]/route.ts | 2 +- app/api/spotify/auth/route.ts | 13 --- app/api/spotify/callback/route.ts | 46 --------- app/api/spotify/route.ts | 10 ++ components/footer/Footer.tsx | 28 +++-- components/footer/SocialMediaLinks.tsx | 29 +++--- components/footer/SpotifyStatus.tsx | 45 ++++---- components/header/MobileMenuToggle.tsx | 37 +++++++ components/header/Navbar.tsx | 108 +++++--------------- components/header/NavbarLinks.tsx | 70 ++++--------- components/header/navbar.module.css | 33 ------ components/login-form.tsx | 92 +++++++++++++++++ components/sections/contact/ContactForm.tsx | 59 +++++------ components/signup-form.tsx | 92 +++++++++++++++++ components/ui/card.tsx | 92 +++++++++++++++++ components/ui/mode-toggle.tsx | 2 +- components/ui/user.tsx | 2 +- lib/actions.ts | 81 +++++++++++++++ lib/auth-client.ts | 11 -- lib/auth/auth-client.ts | 28 +++++ lib/{ => auth}/auth.ts | 7 +- lib/constants/assets.ts | 6 ++ lib/constants/index.ts | 3 + lib/constants/navigation.ts | 8 ++ lib/constants/urls.ts | 3 + lib/schemas/contact.ts | 9 ++ lib/socialMedia.ts | 74 ++++++-------- lib/types/index.ts | 22 ++++ proxy.ts | 2 +- public/images/CoC Logo.jpeg | Bin 0 -> 15444 bytes public/svgs/X.svg | 3 + 33 files changed, 683 insertions(+), 356 deletions(-) create mode 100644 app/(auth)/login/page.tsx create mode 100644 app/(auth)/signup/page.tsx delete mode 100644 app/api/spotify/auth/route.ts delete mode 100644 app/api/spotify/callback/route.ts create mode 100644 components/header/MobileMenuToggle.tsx delete mode 100644 components/header/navbar.module.css create mode 100644 components/login-form.tsx create mode 100644 components/signup-form.tsx create mode 100644 components/ui/card.tsx create mode 100644 lib/actions.ts delete mode 100644 lib/auth-client.ts create mode 100644 lib/auth/auth-client.ts rename lib/{ => auth}/auth.ts (95%) create mode 100644 lib/constants/assets.ts create mode 100644 lib/constants/index.ts create mode 100644 lib/constants/navigation.ts create mode 100644 lib/constants/urls.ts create mode 100644 lib/schemas/contact.ts create mode 100644 lib/types/index.ts create mode 100644 public/images/CoC Logo.jpeg create mode 100644 public/svgs/X.svg diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..78e26a1 --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,11 @@ +import { LoginForm } from "@/components/login-form" + +export default function LoginPage() { + return ( +
+
+ +
+
+ ) +} \ No newline at end of file diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx new file mode 100644 index 0000000..0db67b6 --- /dev/null +++ b/app/(auth)/signup/page.tsx @@ -0,0 +1,11 @@ +import { SignupForm } from "@/components/signup-form" + +export default function SignupPage() { + return ( +
+
+ +
+
+ ) +} \ No newline at end of file diff --git a/app/api/auth/[...all]/route.ts b/app/api/auth/[...all]/route.ts index 370bead..69a0760 100644 --- a/app/api/auth/[...all]/route.ts +++ b/app/api/auth/[...all]/route.ts @@ -1,4 +1,4 @@ -import { auth } from "@/lib/auth"; +import { auth } from "@/lib/auth/auth"; import { toNextJsHandler } from "better-auth/next-js"; export const { POST, GET } = toNextJsHandler(auth); \ No newline at end of file diff --git a/app/api/spotify/auth/route.ts b/app/api/spotify/auth/route.ts deleted file mode 100644 index da31d28..0000000 --- a/app/api/spotify/auth/route.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { NextResponse } from 'next/server'; - -export async function GET() { - // Redirect to Spotify authorization URL - const authURL = new URL('https://accounts.spotify.com/authorize'); - authURL.searchParams.append('client_id', process.env.SPOTIFY_CLIENT_ID!); - authURL.searchParams.append('response_type', 'code'); - authURL.searchParams.append('redirect_uri', process.env.SPOTIFY_REDIRECT_URI!); - authURL.searchParams.append('scope', 'user-read-currently-playing user-read-playback-state'); - - console.log("Authorization URL:", authURL.toString()); - return NextResponse.redirect(authURL); -} \ No newline at end of file diff --git a/app/api/spotify/callback/route.ts b/app/api/spotify/callback/route.ts deleted file mode 100644 index 06fa7b6..0000000 --- a/app/api/spotify/callback/route.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { NextResponse } from "next/server"; - -export async function GET(request: Request) { - const { searchParams, href } = new URL(request.url); - console.log("Callback URL:", href); // Debug - const code = searchParams.get("code"); - if (!code) { - return NextResponse.json({ error: "No code provided" }, { status: 400 }); - } - - try { - const response = await fetch("https://accounts.spotify.com/api/token", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Authorization: `Basic ${Buffer.from( - `${process.env.SPOTIFY_CLIENT_ID}:${process.env.SPOTIFY_CLIENT_SECRET}` - ).toString("base64")}`, - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - code, - redirect_uri: process.env.SPOTIFY_REDIRECT_URI!, - }), - }); - - const data = await response.json(); - if (!response.ok) { - throw new Error(data.error || "Failed to exchange code"); - } - - const { refresh_token } = data; - console.log("Refresh Token:", refresh_token); - - return NextResponse.json({ - message: "Copy the refresh_token to your .env.local", - refresh_token, - }); - } catch (error) { - console.error("Token exchange error:", error); - return NextResponse.json( - { error: "Failed to get tokens" }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/app/api/spotify/route.ts b/app/api/spotify/route.ts index 469cf59..f64f17a 100644 --- a/app/api/spotify/route.ts +++ b/app/api/spotify/route.ts @@ -1,5 +1,15 @@ import { NextResponse } from "next/server"; +/** + * Spotify "Now Playing" API + * + * Setup (one-time): + * 1. Go to https://accounts.spotify.com/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=YOUR_REDIRECT_URI&scope=user-read-currently-playing%20user-read-playback-state + * 2. Authorize and copy the `code` from the redirect URL + * 3. Exchange it for tokens via POST to https://accounts.spotify.com/api/token + * 4. Save the refresh_token to SPOTIFY_REFRESH_TOKEN in .env.local + */ + // In-memory store for access token and expiration let accessToken: string | null = null; let tokenExpiration: number | null = null; diff --git a/components/footer/Footer.tsx b/components/footer/Footer.tsx index 754688e..a016d31 100644 --- a/components/footer/Footer.tsx +++ b/components/footer/Footer.tsx @@ -1,31 +1,39 @@ import Image from "next/image"; -import SpotifyStatus from './SpotifyStatus'; -import SocialMediaLinks from './SocialMediaLinks'; -import Copyright from './Copyright'; +import SpotifyStatus from "./SpotifyStatus"; +import SocialMediaLinks from "./SocialMediaLinks"; +import Copyright from "./Copyright"; +import { ASSETS } from "@/lib/constants"; export default function Footer() { - const baseUrl = "/svgs/"; - return ( ); -} \ No newline at end of file +} diff --git a/components/footer/SocialMediaLinks.tsx b/components/footer/SocialMediaLinks.tsx index 5ae28d5..15d4e4e 100644 --- a/components/footer/SocialMediaLinks.tsx +++ b/components/footer/SocialMediaLinks.tsx @@ -1,21 +1,26 @@ -import Image from 'next/image'; -import { socialMedia } from '@/lib/socialMedia'; - - -export interface SocialMediaItem { - name: string; - url: string; - icon: string; -} +import Image from "next/image"; +import { socialMedia } from "@/lib/socialMedia"; export default function SocialMediaLinks() { return (
{socialMedia.map((media) => ( - - {`${media.name} + + {`${media.name} ))}
); -} \ No newline at end of file +} diff --git a/components/footer/SpotifyStatus.tsx b/components/footer/SpotifyStatus.tsx index dc8df60..14d059d 100644 --- a/components/footer/SpotifyStatus.tsx +++ b/components/footer/SpotifyStatus.tsx @@ -1,26 +1,19 @@ -'use client'; -import { useEffect, useState } from 'react'; -import { motion, AnimatePresence } from 'framer-motion'; +"use client"; +import { useEffect, useState } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import type { SpotifyStatus } from "@/lib/types"; -interface SpotifyStatus { - isListening: boolean; - trackName?: string; - artistName?: string; - message?: string; - itemType?: string; -} - -export default function SpotifyStatus() { +export default function SpotifyStatusComponent() { const [status, setStatus] = useState({ isListening: false }); useEffect(() => { async function fetchSpotifyStatus() { try { - const response = await fetch('api/spotify'); + const response = await fetch("api/spotify"); const data: SpotifyStatus = await response.json(); setStatus(data); } catch { - setStatus({ isListening: false, message: 'Error fetching Spotify data' }); + setStatus({ isListening: false, message: "Error fetching Spotify data" }); } } fetchSpotifyStatus(); @@ -30,10 +23,10 @@ export default function SpotifyStatus() { const formatNowPlaying = () => { if (!status.isListening) { - return status.message || 'Not listening to Spotify'; + return status.message || "Not listening to Spotify"; } - if (status.itemType === 'episode') { + if (status.itemType === "episode") { return `Now Playing: ${status.trackName} from ${status.artistName}`; } else { return `Now Playing: ${status.trackName} by ${status.artistName}`; @@ -41,12 +34,16 @@ export default function SpotifyStatus() { }; return ( - <> - - - {formatNowPlaying()} - - - + + + {formatNowPlaying()} + + ); -} \ No newline at end of file +} diff --git a/components/header/MobileMenuToggle.tsx b/components/header/MobileMenuToggle.tsx new file mode 100644 index 0000000..2b11433 --- /dev/null +++ b/components/header/MobileMenuToggle.tsx @@ -0,0 +1,37 @@ +"use client"; +import { useState } from "react"; +import NavbarLinks from "./NavbarLinks"; + +export default function MobileMenuToggle() { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + + + {isOpen && ( +
+
+ setIsOpen(false)} + /> +
+
+ )} + + ); +} diff --git a/components/header/Navbar.tsx b/components/header/Navbar.tsx index d8f9ca4..f9b9eeb 100644 --- a/components/header/Navbar.tsx +++ b/components/header/Navbar.tsx @@ -1,85 +1,29 @@ -'use client'; -import { useState, useEffect } from 'react'; -import Link from 'next/link'; -import NavbarLinks from './NavbarLinks'; -import classes from './navbar.module.css'; -import { Avatar, AvatarImage } from '@/components/ui/avatar'; -import { Skeleton } from '@/components/ui/skeleton'; -import { ModeToggle } from '@/components/ui/mode-toggle'; -import { ProfileToggle } from '@/components/ui/user'; +import Link from "next/link"; +import NavbarLinks from "./NavbarLinks"; +import MobileMenuToggle from "./MobileMenuToggle"; +import { Avatar, AvatarImage } from "@/components/ui/avatar"; +import { ModeToggle } from "@/components/ui/mode-toggle"; +import { ProfileToggle } from "@/components/ui/user"; +import { ASSETS } from "@/lib/constants"; export default function Navbar() { - const imageSrc = "/images/Logo.jpeg"; - const [isOpen, setIsOpen] = useState(false); - const [isClosing, setIsClosing] = useState(false); - const [isImageLoaded, setIsImageLoaded] = useState(false); - const [scrolled, setScrolled] = useState(false); + return ( +
+ +
+ ); +} diff --git a/components/header/NavbarLinks.tsx b/components/header/NavbarLinks.tsx index 7d72ff6..d7b191f 100644 --- a/components/header/NavbarLinks.tsx +++ b/components/header/NavbarLinks.tsx @@ -1,54 +1,26 @@ -'use client'; -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; - -interface NavLinkItem { - name: string; - href: string; - target?: string; - rel?: string; - isContactLink?: boolean; -} - -const navLinks: NavLinkItem[] = [ - { name: 'Placeholder 1', href: '#' }, - { name: 'Placeholder 2', href: '#' }, - { name: 'Placeholder 3', href: '#' }, - { name: 'Contact', href: '/#contact-section', isContactLink: true }, -]; +"use client"; +import Link from "next/link"; +import { NAV_LINKS } from "@/lib/constants"; interface NavbarLinksProps { - className?: string; - onLinkClick?: () => void; + className?: string; + onLinkClick?: () => void; } export default function NavbarLinks({ className, onLinkClick }: NavbarLinksProps) { - const pathname = usePathname(); - - const handleLinkClick = (isContactLink?: boolean, event?: React.MouseEvent) => { - if (onLinkClick) { - onLinkClick(); - } - - if (isContactLink && pathname === '/') { - if (event) event.preventDefault(); - const contactSection = document.getElementById('contact-section'); - if (contactSection) { - contactSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - } - }; - - return ( -
    - {navLinks.map((link) => ( -
  • - handleLinkClick(link.isContactLink, e)} className="no-underline block"> - {link.name} - - -
  • - ))} -
- ); -} \ No newline at end of file + return ( +
    + {NAV_LINKS.map(({ name, href, target, rel }) => ( +
  • + + {name} + + +
  • + ))} +
+ ); +} diff --git a/components/header/navbar.module.css b/components/header/navbar.module.css deleted file mode 100644 index c6245b7..0000000 --- a/components/header/navbar.module.css +++ /dev/null @@ -1,33 +0,0 @@ -.animate-slide-down { - animation: slide-down 0.7s cubic-bezier(0.4, 0, 0.2, 1); -} - -.animate-slide-up { - animation: slide-up 0.7s cubic-bezier(0.4, 0, 0.2, 1) forwards; -} - -@keyframes slide-down { - from { - transform: translateY(-20px); - max-height: 0; - opacity: 0; - } - to { - transform: translateY(0); - max-height: 600px; - opacity: 1; - } -} - -@keyframes slide-up { - from { - opacity: 1; - transform: translateY(0); - max-height: 600px; - } - to { - opacity: 0; - transform: translateY(-20px); - max-height: 0; - } -} \ No newline at end of file diff --git a/components/login-form.tsx b/components/login-form.tsx new file mode 100644 index 0000000..5c96a22 --- /dev/null +++ b/components/login-form.tsx @@ -0,0 +1,92 @@ +"use client" +import Image from "next/image" +import Link from "next/link" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { toast } from "sonner" +import { useActionState, useEffect } from "react" +import { signIn } from "@/lib/actions" +import { loginWithSocial } from "@/lib/auth/auth-client" + +export function LoginForm({ className, ...props }: React.ComponentProps<"div">) { + const initialState = { errorMessage: "" }; + const [state, formAction, pending] = useActionState(signIn, initialState) + + useEffect(() => { + if (state.errorMessage && state.errorMessage.length) { + toast.error(state.errorMessage) + } + }, [state.errorMessage]) + + return ( +
+ + +
+
+
+

Welcome back

+

+ Login to your BattlePlan account +

+
+
+ + +
+
+
+ + + Forgot your password? + +
+ +
+ +
+ + Or continue with + +
+
+ + + +
+
+ Don't have an account?{" "} + + Sign up + +
+
+
+
+ BattlePlan Logo +
+
+
+
+ ) +} \ No newline at end of file diff --git a/components/sections/contact/ContactForm.tsx b/components/sections/contact/ContactForm.tsx index 239593c..b8c0027 100644 --- a/components/sections/contact/ContactForm.tsx +++ b/components/sections/contact/ContactForm.tsx @@ -1,27 +1,26 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; -import { z } from "zod"; import { useState } from "react"; -import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form"; +import { + Form, + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, +} from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Button } from "@/components/ui/button"; -import { toast } from "sonner" - - -const formSchema = z.object({ - name: z.string().min(2, "Name must be at least 2 characters long"), - email: z.string().email("Invalid email address"), - message: z.string().min(10, "Message must be at least 10 characters long"), -}); +import { toast } from "sonner"; +import { contactFormSchema, type ContactFormData } from "@/lib/schemas/contact"; export function ContactForm() { const [isSubmitting, setIsSubmitting] = useState(false); - // Define the form schema using Zod - const form = useForm>({ - resolver: zodResolver(formSchema), + const form = useForm({ + resolver: zodResolver(contactFormSchema), defaultValues: { name: "", email: "", @@ -29,15 +28,14 @@ export function ContactForm() { }, }); - // Handle form submission - async function onSubmit(data: z.infer) { + async function onSubmit(data: ContactFormData) { setIsSubmitting(true); try { - const response = await fetch('/api/email', { - method: 'POST', + const response = await fetch("/api/email", { + method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, body: JSON.stringify(data), }); @@ -45,21 +43,21 @@ export function ContactForm() { const result = await response.json(); if (response.ok) { - toast('Message sent successfully! I\'ll get back to you soon.', { - position: 'bottom-right', + toast("Message sent successfully! I'll get back to you soon.", { + position: "bottom-right", duration: 5000, }); form.reset(); } else { - toast(result.error || 'Failed to send message. Please try again.', { - position: 'bottom-right', + toast(result.error || "Failed to send message. Please try again.", { + position: "bottom-right", duration: 5000, }); } } catch (error) { - console.error('Form submission error:', error); - toast('Network error. Please check your connection and try again.', { - position: 'top-right', + console.error("Form submission error:", error); + toast("Network error. Please check your connection and try again.", { + position: "top-right", duration: 5000, }); } finally { @@ -128,12 +126,15 @@ export function ContactForm() { /> - {/* Submit Button */} - ); -} \ No newline at end of file +} diff --git a/components/signup-form.tsx b/components/signup-form.tsx new file mode 100644 index 0000000..2db1e96 --- /dev/null +++ b/components/signup-form.tsx @@ -0,0 +1,92 @@ +"use client" +import Image from "next/image" +import Link from "next/link" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { signUp } from "@/lib/actions" +import { useActionState, useEffect } from "react" +import { toast } from "sonner" + +export function SignupForm({ className, ...props }: React.ComponentProps<"div">) { + const initialState = { errorMessage: "" }; + const [state, formAction, pending] = useActionState(signUp, initialState) + + useEffect(() => { + if (state.errorMessage && state.errorMessage.length) { + toast.error(state.errorMessage) + } + }, [state.errorMessage]) + + return ( +
+ + +
+
+
+

Welcome!

+

+ Create your BattlePlan account +

+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ +
+ +
+ +
+ + Or continue with + +
+
+ + + +
+
+ Already have an account?{" "} + + Sign in + +
+
+
+
+ BattlePlan Logo +
+
+
+
+ ) +} \ No newline at end of file diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 0000000..681ad98 --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/components/ui/mode-toggle.tsx b/components/ui/mode-toggle.tsx index ee7c498..d888f5d 100644 --- a/components/ui/mode-toggle.tsx +++ b/components/ui/mode-toggle.tsx @@ -22,7 +22,7 @@ export function ModeToggle() { Toggle theme - + setTheme("light")}> Light diff --git a/components/ui/user.tsx b/components/ui/user.tsx index c9bfabc..67efbac 100644 --- a/components/ui/user.tsx +++ b/components/ui/user.tsx @@ -3,7 +3,7 @@ import Link from "next/link" import { UserRound } from "lucide-react" import { Button } from "@/components/ui/button" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" -import { authClient } from "@/lib/auth-client" +import { authClient } from "@/lib/auth/auth-client" import { useRouter } from "next/navigation" import { toast } from "sonner" diff --git a/lib/actions.ts b/lib/actions.ts new file mode 100644 index 0000000..0e86858 --- /dev/null +++ b/lib/actions.ts @@ -0,0 +1,81 @@ +"use server" +import { redirect } from "next/navigation"; +import { auth } from "./auth/auth"; +import { APIError } from "better-auth/api"; + +interface State { + errorMessage?: string | null; +} + +export async function signUp(prevState: State, formData: FormData) { + const rawFormData = { + firstName: formData.get("firstName") as string, + lastName: formData.get("lastName") as string, + email: formData.get("email") as string, + password: formData.get("password") as string, + }; + + const { firstName, lastName, email, password } = rawFormData; + + try { + await auth.api.signUpEmail({ + body: { + name: `${firstName} ${lastName}`, + email, + password, + }, + }); + } catch (error) { + if (error instanceof APIError) { + switch (error.status) { + case "UNPROCESSABLE_ENTITY": + return { errorMessage: "User already exists." }; + case "BAD_REQUEST": + return { errorMessage: "Invalid email." }; + default: + return { errorMessage: "Something went wrong." }; + } + } + console.error(error); + } + + redirect("/"); +} + +export async function signIn(prevState: State, formData: FormData) { + const rawFormData = { + email: formData.get("email") as string, + password: formData.get("password") as string, + }; + + const { email, password } = rawFormData; + + try { + await auth.api.signInEmail({ + body: { + email, + password, + }, + }); + } catch (error) { + if (error instanceof APIError) { + console.log(error); + switch (error.status) { + case "UNAUTHORIZED": + return { errorMessage: "Invalid credentials." }; + case "BAD_REQUEST": + return { errorMessage: "Invalid email." }; + default: + return { errorMessage: "Something went wrong." }; + } + } + console.error("Sign in with email and password has not worked.", error); + } + + redirect("/"); +} + +export async function signOut() { + await auth.api.signOut(); + redirect("/auth/login"); +} \ No newline at end of file diff --git a/lib/auth-client.ts b/lib/auth-client.ts deleted file mode 100644 index bef41cd..0000000 --- a/lib/auth-client.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { createAuthClient } from "better-auth/react" -import { stripeClient } from "@better-auth/stripe/client" - -export const authClient = createAuthClient({ - baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL || "http://localhost:3000", - plugins: [ - stripeClient({ - subscription: true - }) - ] -}) \ No newline at end of file diff --git a/lib/auth/auth-client.ts b/lib/auth/auth-client.ts new file mode 100644 index 0000000..acf50ac --- /dev/null +++ b/lib/auth/auth-client.ts @@ -0,0 +1,28 @@ +import { createAuthClient } from "better-auth/react" +import { stripeClient } from "@better-auth/stripe/client" +import { APIError } from "better-auth" + +export const authClient = createAuthClient({ + baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL || "http://localhost:3000", + plugins: [ + stripeClient({ + subscription: true + }) + ] +}) + +export const loginWithSocial = async (provider: string) => { + try { + await authClient.signIn.social({ + provider, + callbackURL: "/", + errorCallbackURL: "/login", + }) + } catch (error) { + if (error instanceof APIError) { + console.error("Social login error:", error.status, error.message) + } else { + console.error("Unexpected error during social login:", error) + } + } +} \ No newline at end of file diff --git a/lib/auth.ts b/lib/auth/auth.ts similarity index 95% rename from lib/auth.ts rename to lib/auth/auth.ts index 3a5a353..3305aa4 100644 --- a/lib/auth.ts +++ b/lib/auth/auth.ts @@ -1,8 +1,9 @@ import { betterAuth } from "better-auth"; import { prismaAdapter } from "better-auth/adapters/prisma"; -import { prisma } from "./prisma"; +import { prisma } from "../prisma"; import { stripe } from "@better-auth/stripe"; import Stripe from "stripe"; +import { nextCookies } from "better-auth/next-js"; // Initialize Stripe integration const stripeClient = new Stripe(process.env.STRIPE_SECRET_KEY!, { @@ -77,6 +78,6 @@ export const auth = betterAuth({ return isOwner; } } - }) - ] + }), + nextCookies()], }); \ No newline at end of file diff --git a/lib/constants/assets.ts b/lib/constants/assets.ts new file mode 100644 index 0000000..9c86676 --- /dev/null +++ b/lib/constants/assets.ts @@ -0,0 +1,6 @@ +export const ASSETS = { + svgBasePath: "/svgs/", + imagesBasePath: "/images/", + logo: "/images/Logo.jpeg", + spotifyIcon: "/svgs/Spotify.svg", +} as const; diff --git a/lib/constants/index.ts b/lib/constants/index.ts new file mode 100644 index 0000000..babc2e6 --- /dev/null +++ b/lib/constants/index.ts @@ -0,0 +1,3 @@ +export * from "./assets"; +export * from "./urls"; +export * from "./navigation"; diff --git a/lib/constants/navigation.ts b/lib/constants/navigation.ts new file mode 100644 index 0000000..a467e75 --- /dev/null +++ b/lib/constants/navigation.ts @@ -0,0 +1,8 @@ +import type { NavLinkItem } from "@/lib/types"; + +export const NAV_LINKS: NavLinkItem[] = [ + { name: "Placeholder 1", href: "#" }, + { name: "Placeholder 2", href: "#" }, + { name: "Placeholder 3", href: "#" }, + { name: "Contact", href: "/#contact-section", isContactLink: true }, +]; diff --git a/lib/constants/urls.ts b/lib/constants/urls.ts new file mode 100644 index 0000000..4ee8882 --- /dev/null +++ b/lib/constants/urls.ts @@ -0,0 +1,3 @@ +export const EXTERNAL_URLS = { + spotifyProfile: "https://open.spotify.com/user/223qjmi62hl4nilhilo6lsdea", +} as const; diff --git a/lib/schemas/contact.ts b/lib/schemas/contact.ts new file mode 100644 index 0000000..3d6e309 --- /dev/null +++ b/lib/schemas/contact.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const contactFormSchema = z.object({ + name: z.string().min(2, "Name must be at least 2 characters long"), + email: z.string().email("Invalid email address"), + message: z.string().min(10, "Message must be at least 10 characters long"), +}); + +export type ContactFormData = z.infer; diff --git a/lib/socialMedia.ts b/lib/socialMedia.ts index 0460e24..b9c3404 100644 --- a/lib/socialMedia.ts +++ b/lib/socialMedia.ts @@ -1,41 +1,35 @@ -interface socialMedia { - name: string; - url: string; - icon: string; - isBlack?: boolean; -} +import type { SocialMediaItem } from "@/lib/types"; +import { ASSETS } from "@/lib/constants"; -const baseUrl = "/svgs/"; - -export const socialMedia: socialMedia[] = [ - { - name: "GitHub", - url: `${process.env.GitHub_URL}`, - icon: `${baseUrl}github.svg`, - isBlack: true, - }, - { - name: "LinkedIn", - url: `${process.env.LinkedIn_URL}`, - icon: `${baseUrl}linkedin.svg`, - isBlack: true, - }, - { - name: "Personal Website", - url: `${process.env.Personal_Website_URL}`, - icon: `${baseUrl}earth.svg`, - isBlack: true, - }, - { - name: "Discord", - url: `${process.env.Discord_Profile}`, - icon: `${baseUrl}discord.svg`, - isBlack: true, - }, - { - name: "Youtube", - url: `${process.env.Youtube_URL}`, - icon: `${baseUrl}youtube.svg`, - isBlack: true, - } -]; \ No newline at end of file +export const socialMedia: SocialMediaItem[] = [ + { + name: "GitHub", + url: `${process.env.GitHub_URL}`, + icon: `${ASSETS.svgBasePath}github.svg`, + isBlack: true, + }, + { + name: "LinkedIn", + url: `${process.env.LinkedIn_URL}`, + icon: `${ASSETS.svgBasePath}linkedin.svg`, + isBlack: true, + }, + { + name: "Personal Website", + url: `${process.env.Personal_Website_URL}`, + icon: `${ASSETS.svgBasePath}earth.svg`, + isBlack: true, + }, + { + name: "Discord", + url: `${process.env.Discord_Profile}`, + icon: `${ASSETS.svgBasePath}discord.svg`, + isBlack: true, + }, + { + name: "Youtube", + url: `${process.env.Youtube_URL}`, + icon: `${ASSETS.svgBasePath}youtube.svg`, + isBlack: true, + }, +]; diff --git a/lib/types/index.ts b/lib/types/index.ts new file mode 100644 index 0000000..afc5c8d --- /dev/null +++ b/lib/types/index.ts @@ -0,0 +1,22 @@ +export interface SocialMediaItem { + name: string; + url: string; + icon: string; + isBlack?: boolean; +} + +export interface SpotifyStatus { + isListening: boolean; + trackName?: string; + artistName?: string; + message?: string; + itemType?: "track" | "episode"; +} + +export interface NavLinkItem { + name: string; + href: string; + target?: string; + rel?: string; + isContactLink?: boolean; +} diff --git a/proxy.ts b/proxy.ts index a68ca4e..bbaa6fe 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { headers } from "next/headers"; -import { auth } from "@/lib/auth"; +import { auth } from "@/lib/auth/auth"; export async function proxy(request: NextRequest) { const session = await auth.api.getSession({ diff --git a/public/images/CoC Logo.jpeg b/public/images/CoC Logo.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..8b9a3a2ea9f01e557abb8c95f49be4e45fabc950 GIT binary patch literal 15444 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 aX|B=5U2N!;09yN+ntkY_F0JH!>3;x@`GS4` literal 0 HcmV?d00001 diff --git a/public/svgs/X.svg b/public/svgs/X.svg new file mode 100644 index 0000000..437e2bf --- /dev/null +++ b/public/svgs/X.svg @@ -0,0 +1,3 @@ + + +