Compare commits
4 Commits
d50555cbc4
...
9d5f57c13e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d5f57c13e | ||
|
|
8aba6e31c8 | ||
|
|
be22888afb | ||
|
|
6d8056d483 |
@@ -119,4 +119,7 @@
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
.pricing-page {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
import { Header } from "@/components/ui/header";
|
||||
import { ThemeProvider } from "@/lib/theme-context";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -29,10 +30,10 @@ export default function RootLayout({
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<Header/>
|
||||
|
||||
{children}
|
||||
|
||||
<ThemeProvider>
|
||||
<Header/>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
110
app/pricing/page.tsx
Normal file
110
app/pricing/page.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PricingCard from '@/components/pricing-card';
|
||||
import { useTheme } from '@/lib/theme-context';
|
||||
import PricingToggle from '@/components/ui/pricing-toggle';
|
||||
|
||||
const PricingPage = () => {
|
||||
const [isYearly, setIsYearly] = useState(false);
|
||||
const { theme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
document.body.classList.add('pricing-page');
|
||||
}, []);
|
||||
|
||||
const toggleBillingPeriod = () => {
|
||||
setIsYearly(!isYearly);
|
||||
};
|
||||
|
||||
const getPrice = (basePrice: number) => {
|
||||
return isYearly ? basePrice * 10 : basePrice;
|
||||
};
|
||||
|
||||
const getBillingPeriod = () => {
|
||||
return isYearly ? 'year' : 'month';
|
||||
};
|
||||
|
||||
const getDiscountBadge = () => {
|
||||
if (isYearly) {
|
||||
return { text: 'Save 20%', style: 'text-green-500 dark:text-green-400' };
|
||||
}
|
||||
return { text: '', style: '' };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen ${theme === 'dark' ? 'bg-gray-900 text-gray-100' : 'bg-gray-50 text-gray-900'} transition-colors duration-300`}>
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-extrabold sm:text-4xl md:text-5xl transition-all duration-300">
|
||||
Find a plan to power your apps.
|
||||
</h1>
|
||||
<p className="mt-3 text-lg sm:text-xl transition-opacity duration-300">
|
||||
Dishpix supports teams of all sizes, with pricing that scales.
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-medium text-indigo-600 dark:text-indigo-400 transition-colors duration-300">
|
||||
{getDiscountBadge().text}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Pricing Toggle (Monthly/Yearly) */}
|
||||
<PricingToggle isYearly={isYearly} onToggle={toggleBillingPeriod} />
|
||||
|
||||
{/* Pricing Cards Grid */}
|
||||
<div className="mt-12 sm:mt-16 lg:mt-20 space-y-8 sm:space-y-12 md:space-y-16 lg:space-y-0 sm:grid sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 sm:gap-6 lg:gap-8 transition-all duration-300">
|
||||
<PricingCard
|
||||
title="Start"
|
||||
price={`$${getPrice(9)}`}
|
||||
billingPeriod={getBillingPeriod()}
|
||||
features={[
|
||||
'Import your repo, deploy in seconds',
|
||||
'Automatic CI/CD',
|
||||
'Web Application Firewall',
|
||||
'Global, automated CDN',
|
||||
'Fluid compute',
|
||||
'DDoS Mitigation',
|
||||
'Traffic & performance insights',
|
||||
]}
|
||||
ctaText="Start Deploying"
|
||||
ctaHref="#"
|
||||
/>
|
||||
<PricingCard
|
||||
title="Pro"
|
||||
price={`$${getPrice(29)}`}
|
||||
billingPeriod={getBillingPeriod()}
|
||||
features={[
|
||||
'Everything in Start, plus:',
|
||||
'10x more included usage',
|
||||
'Observability tools',
|
||||
'Faster builds',
|
||||
'Cold start prevention',
|
||||
'Advanced WAF Protection',
|
||||
'Email support',
|
||||
]}
|
||||
ctaText="Start a free trial"
|
||||
ctaHref="#"
|
||||
isPopular={true}
|
||||
/>
|
||||
<PricingCard
|
||||
title="Premium"
|
||||
price={`$${getPrice(99)}`}
|
||||
billingPeriod={getBillingPeriod()}
|
||||
features={[
|
||||
'Everything in Pro, plus:',
|
||||
'Guest & Team access controls',
|
||||
'SCIM & Directory Sync',
|
||||
'Managed WAF Rulesets',
|
||||
'Multi-region compute & failover',
|
||||
'99.99% SLA',
|
||||
'Advanced Support',
|
||||
]}
|
||||
ctaText="Contact Sales"
|
||||
ctaHref="#"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingPage;
|
||||
87
components/pricing-card.tsx
Normal file
87
components/pricing-card.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '@/lib/theme-context';
|
||||
|
||||
interface PricingCardProps {
|
||||
title: string;
|
||||
price: string;
|
||||
billingPeriod: string;
|
||||
features: string[];
|
||||
ctaText: string;
|
||||
ctaHref: string;
|
||||
isPopular?: boolean;
|
||||
}
|
||||
|
||||
const PricingCard: React.FC<PricingCardProps> = ({
|
||||
title,
|
||||
price,
|
||||
billingPeriod,
|
||||
features,
|
||||
ctaText,
|
||||
ctaHref,
|
||||
isPopular = false,
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const cardBg = theme === 'dark' ? 'bg-gray-800' : 'bg-white';
|
||||
const cardText = theme === 'dark' ? 'text-gray-100' : 'text-gray-900';
|
||||
const cardBorder = theme === 'dark' ? 'border-gray-700' : 'border-gray-200';
|
||||
const cardShadow = theme === 'dark' ? 'shadow-xl' : 'shadow-2xl';
|
||||
const cardHover = theme === 'dark' ? 'hover:bg-gray-700' : 'hover:bg-indigo-700';
|
||||
const cardFocus = theme === 'dark' ? 'focus:ring-indigo-500' : 'focus:ring-indigo-500';
|
||||
const cardPopular = theme === 'dark' ? 'bg-indigo-700' : 'bg-indigo-600';
|
||||
const cardFeatureText = theme === 'dark' ? 'text-gray-300' : 'text-gray-700';
|
||||
const cardFeatureIcon = theme === 'dark' ? 'text-green-400' : 'text-green-500';
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col ${cardBg} border ${cardBorder} rounded-xl ${cardShadow} overflow-hidden transition-all duration-300 transform hover:scale-105`}>
|
||||
{isPopular && (
|
||||
<div className={`${cardPopular} text-white text-xs font-semibold px-3 py-1.5 rounded-t-xl`}>
|
||||
Most Popular
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6">
|
||||
<h3 className={`text-2xl font-bold ${cardText}`}>{title}</h3>
|
||||
<p className="mt-4 text-sm text-gray-400">
|
||||
{title === 'Start' && 'The perfect starting place for your web app or personal project.'}
|
||||
{title === 'Pro' && 'Everything you need to build and scale your app.'}
|
||||
{title === 'Premium' && 'Critical security, performance, observability, platform SLAs, and support.'}
|
||||
</p>
|
||||
<p className="mt-8">
|
||||
<span className="text-5xl font-extrabold">{price}</span>
|
||||
<span className="text-base font-medium text-gray-400">/{billingPeriod}</span>
|
||||
</p>
|
||||
<a
|
||||
href={ctaHref}
|
||||
className={`mt-8 block w-full py-3 px-6 border border-transparent rounded-md shadow-lg text-center text-white bg-indigo-600 ${cardHover} focus:outline-none focus:ring-2 focus:ring-offset-2 ${cardFocus} transition-all duration-200`}
|
||||
>
|
||||
{ctaText}
|
||||
</a>
|
||||
</div>
|
||||
<div className={`border-t ${cardBorder} p-6`}>
|
||||
<h4 className={`text-sm font-medium ${cardText}`}>What's included:</h4>
|
||||
<ul className="mt-4 space-y-3">
|
||||
{features.map((feature, index) => (
|
||||
<li key={index} className="flex items-start">
|
||||
<svg
|
||||
className={`h-5 w-5 ${cardFeatureIcon} flex-shrink-0`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className={`ml-3 text-sm ${cardFeatureText}`}>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingCard;
|
||||
@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils"
|
||||
import Link from "next/link"
|
||||
import { useState, useEffect } from "react"
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
interface HeaderProps {
|
||||
className?: string
|
||||
@@ -13,16 +13,25 @@ interface HeaderProps {
|
||||
isAdmin?: boolean
|
||||
}
|
||||
|
||||
const navigation = [
|
||||
const leftNavigation = [
|
||||
{ name: 'Home', href: '/', current: false, requiresAuth: false },
|
||||
{ name: 'Pricing', href: '/pricing', current: false, requiresAuth: false },
|
||||
{ name: 'FAQ', href: '/faq', current: false, requiresAuth: false },
|
||||
{ name: 'Dashboard', href: '/dashboard', current: false, requiresAuth: false },
|
||||
{ name: 'Dashboard', href: '/dashboard', current: false, requiresAuth: true },
|
||||
{ name: 'Projects', href: '/projects', current: false, requiresAuth: true },
|
||||
{ name: 'Calendar', href: '/calendar', current: false, requiresAuth: false },
|
||||
{ name: 'Calendar', href: '/calendar', current: false, requiresAuth: true },
|
||||
{ name: 'Reports', href: '/reports', current: false, requiresAuth: true },
|
||||
]
|
||||
|
||||
const rightNavigation = [
|
||||
{ name: 'Sign-up', href: '/sign-up', current: false, requiresAuth: false },
|
||||
{ name: 'Login', href: '/login', current: false, requiresAuth: false },
|
||||
];
|
||||
|
||||
const ThemeToggle = dynamic(() => import('./theme-toggle').then(mod => mod.default), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export function Header({ className, isLoggedIn = false, isAdmin = false }: HeaderProps) {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [profileOpen, setProfileOpen] = useState(false)
|
||||
@@ -59,7 +68,7 @@ export function Header({ className, isLoggedIn = false, isAdmin = false }: Heade
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<div className="ml-10 flex items-baseline space-x-4">
|
||||
{navigation.map((item) => {
|
||||
{leftNavigation.map((item) => {
|
||||
const isCurrent = activeNavItem === item.href
|
||||
return (
|
||||
(item.requiresAuth ? (isLoggedIn || isAdmin) : true) && (
|
||||
@@ -83,55 +92,80 @@ export function Header({ className, isLoggedIn = false, isAdmin = false }: Heade
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoggedIn && (
|
||||
<div className="hidden md:block">
|
||||
<div className="ml-4 flex items-center md:ml-6">
|
||||
<button
|
||||
type="button"
|
||||
className="relative rounded-full bg-gray-800 p-1 text-gray-400 hover:text-white focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-gray-800"
|
||||
>
|
||||
<span className="absolute -inset-1.5" />
|
||||
<span className="sr-only">View notifications</span>
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Profile dropdown */}
|
||||
<div className="relative ml-3">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="relative flex max-w-xs items-center rounded-full bg-gray-800 text-sm focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-gray-800"
|
||||
onClick={() => setProfileOpen(!profileOpen)}
|
||||
>
|
||||
<span className="absolute -inset-1.5" />
|
||||
<span className="sr-only">Open user menu</span>
|
||||
<img
|
||||
className="h-8 w-8 rounded-full"
|
||||
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
|
||||
alt=""
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{profileOpen && (
|
||||
<div className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<Link href="/profile" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
Your Profile
|
||||
</Link>
|
||||
<Link href="/settings" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
Settings
|
||||
</Link>
|
||||
<Link href="/logout" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
Sign out
|
||||
</Link>
|
||||
<div className="hidden md:block">
|
||||
<div className="ml-4 flex items-center md:ml-6">
|
||||
{!isLoggedIn && (
|
||||
<div className="hidden md:block">
|
||||
<div className="ml-10 flex items-baseline space-x-4">
|
||||
<div>
|
||||
{rightNavigation.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className='rounded-md px-3 py-2 text-sm font-medium transition-colors text-gray-300 hover:bg-gray-700 hover:text-white'
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/*
|
||||
<div className="rounded-md px-3 py-2 text-sm font-medium transition-colors text-gray-300 hover:bg-gray-700 hover:text-white">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
*/}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoggedIn && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="mx-4 relative rounded-full bg-gray-800 p-1 text-gray-400 hover:text-white focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-gray-800"
|
||||
>
|
||||
<span className="absolute -inset-1.5" />
|
||||
<span className="sr-only">View notifications</span>
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Profile dropdown */}
|
||||
<div className="relative ml-3">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="relative flex max-w-xs items-center rounded-full bg-gray-800 text-sm focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-gray-800"
|
||||
onClick={() => setProfileOpen(!profileOpen)}
|
||||
>
|
||||
<span className="absolute -inset-1.5" />
|
||||
<span className="sr-only">Open user menu</span>
|
||||
<img
|
||||
className="h-8 w-8 rounded-full"
|
||||
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
|
||||
alt=""
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{profileOpen && (
|
||||
<div className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<Link href="/profile" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
Your Profile
|
||||
</Link>
|
||||
<Link href="/settings" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
Settings
|
||||
</Link>
|
||||
<Link href="/logout" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
Sign out
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="-mr-2 flex md:hidden">
|
||||
{/* Mobile menu button */}
|
||||
@@ -160,7 +194,7 @@ export function Header({ className, isLoggedIn = false, isAdmin = false }: Heade
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden">
|
||||
<div className="space-y-1 px-2 pb-3 pt-2 sm:px-3">
|
||||
{navigation.map((item) => {
|
||||
{leftNavigation.map((item) => {
|
||||
const isCurrent = activeNavItem === item.href
|
||||
return (
|
||||
(item.requiresAuth ? (isLoggedIn || isAdmin) : true) && (
|
||||
@@ -181,8 +215,8 @@ export function Header({ className, isLoggedIn = false, isAdmin = false }: Heade
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="border-t border-gray-700 pb-3 pt-4">
|
||||
{isLoggedIn && (
|
||||
{isLoggedIn && (
|
||||
<div className="border-t border-gray-700 pb-3 pt-4">
|
||||
<div>
|
||||
<div className="flex items-center px-5">
|
||||
<div className="flex-shrink-0">
|
||||
@@ -219,8 +253,9 @@ export function Header({ className, isLoggedIn = false, isAdmin = false }: Heade
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
44
components/ui/pricing-toggle.tsx
Normal file
44
components/ui/pricing-toggle.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { Switch } from '@headlessui/react';
|
||||
import { useTheme } from '@/lib/theme-context';
|
||||
|
||||
interface PricingToggleProps {
|
||||
isYearly: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
const PricingToggle: React.FC<PricingToggleProps> = ({ isYearly, onToggle }) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const thumbColor = theme === 'dark' ? 'bg-white' : 'bg-gray-900';
|
||||
const trackColor = theme === 'dark' ? 'bg-gray-700' : 'bg-gray-200';
|
||||
const activeTrackColor = theme === 'dark' ? 'bg-indigo-600' : 'bg-indigo-500';
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center mt-12 space-x-4">
|
||||
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
Monthly
|
||||
</span>
|
||||
<div className="relative inline-flex items-center">
|
||||
<Switch
|
||||
checked={isYearly}
|
||||
onChange={onToggle}
|
||||
className={`relative inline-flex items-center h-6 rounded-full w-16 transition-colors duration-200 ease-in-out ${
|
||||
isYearly ? activeTrackColor : trackColor
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block w-7 h-5 transform rounded-full shadow transition-transform duration-200 ease-in-out ${
|
||||
isYearly ? 'translate-x-8' : 'translate-x-0'
|
||||
} ${thumbColor}`}
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
Yearly
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingToggle;
|
||||
23
components/ui/theme-toggle.tsx
Normal file
23
components/ui/theme-toggle.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '@/lib/theme-context';
|
||||
import { Sun, Moon } from 'lucide-react';
|
||||
|
||||
const ThemeToggle: React.FC = () => {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="text-gray-400 hover:text-white focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2"
|
||||
aria-label={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Sun className="h-3 w-3" />
|
||||
) : (
|
||||
<Moon className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeToggle;
|
||||
49
lib/theme-context.tsx
Normal file
49
lib/theme-context.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
import React, { createContext, useState, useEffect, ReactNode, useContext } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export const ThemeProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [theme, setTheme] = useState<Theme>('dark'); // Default to dark mode
|
||||
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem('theme') as Theme | null;
|
||||
if (savedTheme) {
|
||||
setTheme(savedTheme);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme((prevTheme) => (prevTheme === 'dark' ? 'light' : 'dark'));
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useTheme = (): ThemeContextType => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
213
package-lock.json
generated
213
package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "dishpix",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@shadcn/ui": "^0.0.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -232,6 +233,79 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
|
||||
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.3.tgz",
|
||||
"integrity": "sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.3",
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react": {
|
||||
"version": "0.26.28",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz",
|
||||
"integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.1.2",
|
||||
"@floating-ui/utils": "^0.2.8",
|
||||
"tabbable": "^6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react-dom": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.5.tgz",
|
||||
"integrity": "sha512-HDO/1/1oH9fjj4eLgegrlH3dklZpHtUYYFiVwMUwfGvk9jWDRWqkklA2/NFScknrcNSspbV868WjXORvreDX+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
|
||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@headlessui/react": {
|
||||
"version": "2.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.7.tgz",
|
||||
"integrity": "sha512-WKdTymY8Y49H8/gUc/lIyYK1M+/6dq0Iywh4zTZVAaiTDprRfioxSgD0wnXTQTBpjpGJuTL1NO/mqEvc//5SSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.26.16",
|
||||
"@react-aria/focus": "^3.20.2",
|
||||
"@react-aria/interactions": "^3.25.0",
|
||||
"@tanstack/react-virtual": "^3.13.9",
|
||||
"use-sync-external-store": "^1.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
@@ -1006,6 +1080,103 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/focus": {
|
||||
"version": "3.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.0.tgz",
|
||||
"integrity": "sha512-7NEGtTPsBy52EZ/ToVKCu0HSelE3kq9qeis+2eEq90XSuJOMaDHUQrA7RC2Y89tlEwQB31bud/kKRi9Qme1dkA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-aria/interactions": "^3.25.4",
|
||||
"@react-aria/utils": "^3.30.0",
|
||||
"@react-types/shared": "^3.31.0",
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/interactions": {
|
||||
"version": "3.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.4.tgz",
|
||||
"integrity": "sha512-HBQMxgUPHrW8V63u9uGgBymkMfj6vdWbB0GgUJY49K9mBKMsypcHeWkWM6+bF7kxRO728/IK8bWDV6whDbqjHg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-aria/ssr": "^3.9.10",
|
||||
"@react-aria/utils": "^3.30.0",
|
||||
"@react-stately/flags": "^3.1.2",
|
||||
"@react-types/shared": "^3.31.0",
|
||||
"@swc/helpers": "^0.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/ssr": {
|
||||
"version": "3.9.10",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz",
|
||||
"integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/utils": {
|
||||
"version": "3.30.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.30.0.tgz",
|
||||
"integrity": "sha512-ydA6y5G1+gbem3Va2nczj/0G0W7/jUVo/cbN10WA5IizzWIwMP5qhFr7macgbKfHMkZ+YZC3oXnt2NNre5odKw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-aria/ssr": "^3.9.10",
|
||||
"@react-stately/flags": "^3.1.2",
|
||||
"@react-stately/utils": "^3.10.8",
|
||||
"@react-types/shared": "^3.31.0",
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-stately/flags": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz",
|
||||
"integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-stately/utils": {
|
||||
"version": "3.10.8",
|
||||
"resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.8.tgz",
|
||||
"integrity": "sha512-SN3/h7SzRsusVQjQ4v10LaVsDc81jyyR0DD5HnsQitm/I5WDpaSr2nRHtyloPFU48jlql1XX/S04T2DLQM7Y3g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-types/shared": {
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
|
||||
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
@@ -1336,6 +1507,33 @@
|
||||
"tailwindcss": "4.1.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.13.12",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz",
|
||||
"integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.13.12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.13.12",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz",
|
||||
"integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz",
|
||||
@@ -6421,6 +6619,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/tabbable": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
|
||||
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwind-merge": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz",
|
||||
@@ -6751,6 +6955,15 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
|
||||
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@shadcn/ui": "^0.0.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
@@ -8,7 +8,12 @@ module.exports = {
|
||||
'./components/**/*.{js,ts,jsx,tsx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
extend: {
|
||||
transitionProperty: {
|
||||
'width': 'width',
|
||||
'spacing': 'margin, padding',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
createThemes({
|
||||
@@ -21,5 +26,9 @@ module.exports = {
|
||||
foreground: '#ededed',
|
||||
},
|
||||
}),
|
||||
require('tailwindcss/plugin')({
|
||||
darkMode: 'class',
|
||||
}),
|
||||
],
|
||||
darkMode: 'class',
|
||||
}
|
||||
Reference in New Issue
Block a user