Compare commits

...

4 Commits

Author SHA1 Message Date
kbe
e53748d7a9 Wip on dark theme 2025-08-14 18:24:43 +02:00
kbe
8aba6e31c8 Prepare dark theme, add pricing page 2025-08-14 18:15:08 +02:00
kbe
be22888afb Fix gray bar on mobile navbar bottom 2025-08-14 17:43:12 +02:00
kbe
6d8056d483 Add a sign-up and login button in the navbar 2025-08-14 17:42:02 +02:00
8 changed files with 408 additions and 61 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`}
> >
<ThemeProvider>
<Header/> <Header/>
{children} {children}
</ThemeProvider>
</body> </body>
</html> </html>
); );

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

@@ -0,0 +1,123 @@
"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 ? Math.round(basePrice * 10 * 0.8) : 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 bg-gray-200 dark:bg-gray-700 rounded-full p-1">
<button
onClick={toggleBillingPeriod}
className={`relative px-4 py-2 text-sm font-medium rounded-full focus:outline-none transition-colors ${
!isYearly
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-400'
}`}
>
Monthly
</button>
<button
onClick={toggleBillingPeriod}
className={`relative px-4 py-2 text-sm font-medium rounded-full focus:outline-none transition-colors ${
isYearly
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-400'
}`}
>
Yearly
</button>
</div>
</div>
{/* Pricing Cards Grid */}
<div className="mt-12 sm:mt-16 lg:mt-20 space-y-8 sm:space-y-12 md:space-y-0 md:grid md:grid-cols-1 lg:grid-cols-3 md: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="/signup?plan=start"
/>
<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="/signup?plan=pro"
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="/contact-sales"
/>
</div>
</div>
</div>
);
};
export default PricingPage;

100
components/pricing-card.tsx Normal file
View File

@@ -0,0 +1,100 @@
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();
// Add ARIA label for screen readers
const cardAriaLabel = `${title} pricing plan - $${price}/${billingPeriod}`;
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-600';
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';
const cardHoverShadow = theme === 'dark' ? 'hover:shadow-xl' : 'hover:shadow-2xl';
const cardTransition = 'transition-all duration-300';
return (
<div
className={`flex flex-col ${cardBg} border ${cardBorder} rounded-xl ${cardShadow} overflow-hidden ${cardTransition} ${cardHoverShadow}`}
role="article"
aria-label={cardAriaLabel}
tabIndex={0} // Make the card focusable
>
{isPopular && (
<div
className={`${cardPopular} text-white text-xs font-semibold px-3 py-1.5 rounded-t-xl`}
>
Popular
</div>
)}
<div className="p-6">
<h3 className={`text-2xl font-bold ${cardText}`}>{title}</h3>
<p className="mt-2 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-6">
<span className="text-4xl font-extrabold">{price}</span>
<span className="text-base font-medium text-gray-400 ml-1">/{billingPeriod}</span>
</p>
<a
href={ctaHref}
className={`mt-6 block w-full py-3 px-4 border border-transparent rounded-md shadow text-center text-white bg-indigo-600 ${cardHover} focus:outline-none focus:ring-2 focus:ring-offset-2 ${cardFocus} ${cardTransition}`}
aria-label={`${ctaText} for ${title} plan`}
>
{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 React.memo(PricingCard);

View File

@@ -5,7 +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
@@ -13,16 +13,33 @@ interface HeaderProps {
isAdmin?: boolean isAdmin?: boolean
} }
const navigation = [ const leftNavigation = [
{ name: 'Home', href: '/', current: false, requiresAuth: false }, { name: 'Home', href: '/', current: false, requiresAuth: false },
{ name: 'Pricing', href: '/pricing', current: false, requiresAuth: false }, { name: 'Pricing', href: '/pricing', current: false, requiresAuth: false },
{ name: 'FAQ', href: '/faq', 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: '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 }, { 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,
});
const ThemeToggleWrapper = () => {
return (
<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>
);
};
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)
const [profileOpen, setProfileOpen] = useState(false) const [profileOpen, setProfileOpen] = useState(false)
@@ -59,7 +76,7 @@ export function Header({ className, isLoggedIn = false, isAdmin = false }: Heade
</div> </div>
<div className="hidden md:block"> <div className="hidden md:block">
<div className="ml-10 flex items-baseline space-x-4"> <div className="ml-10 flex items-baseline space-x-4">
{navigation.map((item) => { {leftNavigation.map((item) => {
const isCurrent = activeNavItem === item.href const isCurrent = activeNavItem === item.href
return ( return (
(item.requiresAuth ? (isLoggedIn || isAdmin) : true) && ( (item.requiresAuth ? (isLoggedIn || isAdmin) : true) && (
@@ -83,12 +100,32 @@ export function Header({ className, isLoggedIn = false, isAdmin = false }: Heade
</div> </div>
</div> </div>
{isLoggedIn && (
<div className="hidden md:block"> <div className="hidden md:block">
<div className="ml-4 flex items-center md:ml-6"> <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>
<ThemeToggleWrapper />
</div>
</div>
)}
{isLoggedIn && (
<div>
<button <button
type="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" 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="absolute -inset-1.5" />
<span className="sr-only">View notifications</span> <span className="sr-only">View notifications</span>
@@ -130,8 +167,9 @@ export function Header({ className, isLoggedIn = false, isAdmin = false }: Heade
)} )}
</div> </div>
</div> </div>
</div>
)} )}
</div>
</div>
<div className="-mr-2 flex md:hidden"> <div className="-mr-2 flex md:hidden">
{/* Mobile menu button */} {/* Mobile menu button */}
@@ -160,7 +198,7 @@ export function Header({ className, isLoggedIn = false, isAdmin = false }: Heade
{mobileMenuOpen && ( {mobileMenuOpen && (
<div className="md:hidden"> <div className="md:hidden">
<div className="space-y-1 px-2 pb-3 pt-2 sm:px-3"> <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 const isCurrent = activeNavItem === item.href
return ( return (
(item.requiresAuth ? (isLoggedIn || isAdmin) : true) && ( (item.requiresAuth ? (isLoggedIn || isAdmin) : true) && (
@@ -181,8 +219,8 @@ export function Header({ className, isLoggedIn = false, isAdmin = false }: Heade
) )
})} })}
</div> </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>
<div className="flex items-center px-5"> <div className="flex items-center px-5">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
@@ -219,8 +257,9 @@ export function Header({ className, isLoggedIn = false, isAdmin = false }: Heade
</Link> </Link>
</div> </div>
</div> </div>
)}
</div> </div>
)}
</div> </div>
)} )}
</header> </header>

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',
} }