Prepare dark theme, add pricing page

This commit is contained in:
kbe
2025-08-14 18:14:59 +02:00
parent be22888afb
commit 8aba6e31c8
8 changed files with 322 additions and 8 deletions

View File

@@ -119,4 +119,7 @@
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
.pricing-page {
@apply bg-background text-foreground;
}
} }

View File

@@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { Header } from "@/components/ui/header"; import { Header } from "@/components/ui/header";
import { ThemeProvider } from "@/lib/theme-context";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -29,10 +30,10 @@ export default function RootLayout({
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
> >
<Header/> <ThemeProvider>
<Header/>
{children} {children}
</ThemeProvider>
</body> </body>
</html> </html>
); );

134
app/pricing/page.tsx Normal file
View File

@@ -0,0 +1,134 @@
"use client";
import React, { useState, useEffect } from 'react';
import PricingCard from '@/components/pricing-card';
import { useTheme } from '@/lib/theme-context';
import { Sun, Moon } from 'lucide-react';
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';
};
return (
<div className={`min-h-screen ${theme === 'dark' ? 'bg-gray-900 text-gray-100' : 'bg-gray-50 text-gray-900'}`}>
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<div className="text-center">
<h1 className="text-4xl font-extrabold sm:text-5xl">
Find a plan to power your apps.
</h1>
<p className="mt-4 text-xl">
Dishpix supports teams of all sizes, with pricing that scales.
</p>
</div>
{/* Pricing Toggle (Monthly/Yearly) */}
<div className="mt-12 flex justify-center">
<div className="relative inline-flex items-center">
<button
onClick={toggleBillingPeriod}
className={`relative px-4 py-2 text-sm font-medium rounded-l-full focus:outline-none transition-colors ${
!isYearly
? 'bg-white text-gray-700 shadow'
: 'bg-gray-200 text-gray-500'
}`}
>
Monthly
</button>
<span
className={`w-12 h-6 px-1 bg-gray-300 rounded-full transition-transform duration-300 ease-in-out ${
isYearly ? 'translate-x-12' : 'translate-x-0'
}`}
>
<span
className={`w-5 h-5 bg-white rounded-full shadow transform transition-transform duration-300 ease-in-out ${
isYearly ? 'translate-x-6' : 'translate-x-0'
}`}
></span>
</span>
<button
onClick={toggleBillingPeriod}
className={`relative px-4 py-2 text-sm font-medium rounded-r-full focus:outline-none transition-colors ${
isYearly
? 'bg-white text-gray-700 shadow'
: 'bg-gray-200 text-gray-500'
}`}
>
Yearly
</button>
</div>
</div>
{/* Pricing Cards Grid */}
<div className="mt-16 space-y-16 sm:space-y-0 sm:grid sm:grid-cols-1 md:grid-cols-3 sm:gap-6 lg:gap-8">
<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;

View 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-lg' : 'shadow-sm';
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-lg ${cardShadow} overflow-hidden transition-all duration-300`}>
{isPopular && (
<div className={`${cardPopular} text-white text-sm font-medium px-4 py-1`}>
Popular
</div>
)}
<div className="p-6">
<h3 className={`text-xl font-semibold ${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-4xl 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-2 px-4 border border-transparent rounded-md shadow-sm text-center text-white bg-indigo-600 ${cardHover} focus:outline-none focus:ring-2 focus:ring-offset-2 ${cardFocus}`}
>
{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-4">
{features.map((feature, index) => (
<li key={index} className="flex items-start">
<svg
className={`h-5 w-5 ${cardFeatureIcon}`}
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;

View File

@@ -5,6 +5,7 @@ import { cn } from "@/lib/utils"
import Link from "next/link" import Link from "next/link"
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
import dynamic from 'next/dynamic'
interface HeaderProps { interface HeaderProps {
className?: string className?: string
@@ -25,7 +26,11 @@ const leftNavigation = [
const rightNavigation = [ const rightNavigation = [
{ name: 'Sign-up', href: '/sign-up', current: false, requiresAuth: false }, { name: 'Sign-up', href: '/sign-up', current: false, requiresAuth: false },
{ name: 'Login', href: '/login', 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) { export function Header({ className, isLoggedIn = false, isAdmin = false }: HeaderProps) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
@@ -103,6 +108,9 @@ export function Header({ className, isLoggedIn = false, isAdmin = false }: Heade
</Link> </Link>
))} ))}
</div> </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> </div>
)} )}
@@ -206,7 +214,7 @@ export function Header({ className, isLoggedIn = false, isAdmin = false }: Heade
})} })}
</div> </div>
{isLoggedIn && ( {isLoggedIn && (
<div className="border-t border-gray-700 pb-3 pt-4"> <div className="border-t border-gray-700 pb-3 pt-4">
<div> <div>
<div className="flex items-center px-5"> <div className="flex items-center px-5">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
@@ -243,7 +251,7 @@ export function Header({ className, isLoggedIn = false, isAdmin = false }: Heade
</Link> </Link>
</div> </div>
</div> </div>
</div> </div>
)} )}
</div> </div>

View 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
View 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;
};

View File

@@ -8,7 +8,12 @@ module.exports = {
'./components/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}',
], ],
theme: { theme: {
extend: {}, extend: {
transitionProperty: {
'width': 'width',
'spacing': 'margin, padding',
},
},
}, },
plugins: [ plugins: [
createThemes({ createThemes({
@@ -21,5 +26,9 @@ module.exports = {
foreground: '#ededed', foreground: '#ededed',
}, },
}), }),
require('tailwindcss/plugin')({
darkMode: 'class',
}),
], ],
darkMode: 'class',
} }