Initial commit: PortaPower.buzz marketing website

Poop-powered festival website with Next.js 15 static export, Tailwind CSS v4,
and Docker/Nginx/Traefik deployment. Single-page site with hero, how-it-works,
services, impact stats, packages, testimonials, FAQ, and contact sections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-16 08:09:56 +00:00
commit f45d2df4ec
30 changed files with 3514 additions and 0 deletions

8
.dockerignore Normal file
View File

@ -0,0 +1,8 @@
node_modules
.next
out
.git
.gitignore
README.md
.env*
*.md

33
.gitignore vendored Normal file
View File

@ -0,0 +1,33 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env*.local
# typescript
*.tsbuildinfo
next-env.d.ts

12
Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/out /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

184
app/globals.css Normal file
View File

@ -0,0 +1,184 @@
@import "tailwindcss";
@theme {
--color-bg-primary: #1A0E0A;
--color-bg-card: #2A1A12;
--color-bg-card-hover: #3A2A1A;
--color-neon: #39FF14;
--color-neon-dim: #2ECC10;
--color-cream: #FFF8E1;
--color-cream-dim: #E8DFC8;
--color-brown-light: #5C3D2E;
--color-brown-medium: #3E2518;
--color-brown-dark: #2A1A12;
--font-space: "Space Grotesk", sans-serif;
}
@layer base {
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
scroll-behavior: smooth;
scroll-padding-top: 5rem;
}
body {
font-family: var(--font-space);
background-color: var(--color-bg-primary);
color: var(--color-cream);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
::selection {
background-color: var(--color-neon);
color: var(--color-bg-primary);
}
}
/* Animations */
@keyframes float {
0%, 100% { transform: translateY(0) rotate(0deg); }
33% { transform: translateY(-20px) rotate(5deg); }
66% { transform: translateY(-10px) rotate(-3deg); }
}
@keyframes float-slow {
0%, 100% { transform: translateY(0) rotate(0deg); opacity: 0.7; }
50% { transform: translateY(-30px) rotate(10deg); opacity: 1; }
}
@keyframes pulse-glow {
0%, 100% { opacity: 0.4; transform: scale(1); }
50% { opacity: 0.8; transform: scale(1.1); }
}
@keyframes fade-in-up {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slide-in-left {
from { opacity: 0; transform: translateX(-40px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes slide-in-right {
from { opacity: 0; transform: translateX(40px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes wiggle {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(-5deg); }
75% { transform: rotate(5deg); }
}
@keyframes dash-flow {
to { stroke-dashoffset: -20; }
}
/* Utility classes */
.animate-float {
animation: float 6s ease-in-out infinite;
}
.animate-float-slow {
animation: float-slow 8s ease-in-out infinite;
}
.animate-pulse-glow {
animation: pulse-glow 3s ease-in-out infinite;
}
.animate-fade-in-up {
animation: fade-in-up 0.6s ease-out forwards;
}
.animate-fade-in {
animation: fade-in 0.6s ease-out forwards;
}
.animate-slide-in-left {
animation: slide-in-left 0.6s ease-out forwards;
}
.animate-slide-in-right {
animation: slide-in-right 0.6s ease-out forwards;
}
.animate-wiggle {
animation: wiggle 2s ease-in-out infinite;
}
/* Scroll-triggered animation base state */
.reveal {
opacity: 0;
transform: translateY(30px);
transition: opacity 0.6s ease-out, transform 0.6s ease-out;
}
.reveal.visible {
opacity: 1;
transform: translateY(0);
}
/* Neon glow effect */
.neon-text {
text-shadow: 0 0 10px var(--color-neon), 0 0 40px var(--color-neon), 0 0 80px rgba(57, 255, 20, 0.3);
}
.neon-border {
box-shadow: 0 0 10px rgba(57, 255, 20, 0.3), inset 0 0 10px rgba(57, 255, 20, 0.1);
}
.neon-glow-orb {
background: radial-gradient(circle, rgba(57, 255, 20, 0.3) 0%, transparent 70%);
filter: blur(40px);
}
/* Gradient text */
.gradient-text {
background: linear-gradient(135deg, var(--color-neon) 0%, #7CFF5B 50%, var(--color-cream) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Card hover effect */
.card-hover {
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.card-hover:hover {
transform: translateY(-4px);
box-shadow: 0 0 20px rgba(57, 255, 20, 0.15), 0 10px 40px rgba(0, 0, 0, 0.3);
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-bg-primary);
}
::-webkit-scrollbar-thumb {
background: var(--color-brown-light);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-neon-dim);
}

44
app/layout.tsx Normal file
View File

@ -0,0 +1,44 @@
import type { Metadata } from "next";
import { Space_Grotesk } from "next/font/google";
import "./globals.css";
const spaceGrotesk = Space_Grotesk({
subsets: ["latin"],
variable: "--font-space",
display: "swap",
});
export const metadata: Metadata = {
title: "PortaPower — Turning Festival Waste Into Festival Watts",
description:
"PortaPower transforms festival porta potty waste into clean electricity through bioreactor technology. Poop-powered festivals are the future.",
keywords: [
"festival",
"porta potty",
"bioreactor",
"renewable energy",
"waste to energy",
"sustainable festivals",
"poop power",
],
openGraph: {
title: "PortaPower — Turning Festival Waste Into Festival Watts",
description:
"Festival porta potties that generate clean electricity through bioreactor technology.",
url: "https://portapower.buzz",
siteName: "PortaPower",
type: "website",
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={spaceGrotesk.variable}>
<body className="font-space">{children}</body>
</html>
);
}

29
app/page.tsx Normal file
View File

@ -0,0 +1,29 @@
import { Navigation } from "@/components/navigation";
import { HeroSection } from "@/components/hero-section";
import { HowItWorksSection } from "@/components/how-it-works-section";
import { ServicesSection } from "@/components/services-section";
import { ImpactSection } from "@/components/impact-section";
import { PackagesSection } from "@/components/packages-section";
import { TestimonialsSection } from "@/components/testimonials-section";
import { FAQSection } from "@/components/faq-section";
import { ContactSection } from "@/components/contact-section";
import { Footer } from "@/components/footer";
export default function Home() {
return (
<>
<Navigation />
<main>
<HeroSection />
<HowItWorksSection />
<ServicesSection />
<ImpactSection />
<PackagesSection />
<TestimonialsSection />
<FAQSection />
<ContactSection />
</main>
<Footer />
</>
);
}

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@ -0,0 +1,209 @@
"use client";
import { useInView } from "@/hooks/use-in-view";
import { Button } from "@/components/ui/button";
import { Mail, Phone, MapPin, Zap } from "lucide-react";
export function ContactSection() {
const { ref, isInView } = useInView(0.1);
return (
<section id="contact" className="py-24 px-4 bg-brown-dark/30" ref={ref}>
<div className="mx-auto max-w-6xl">
<div className="text-center mb-16">
<h2
className={`text-3xl sm:text-4xl md:text-5xl font-bold mb-4 ${
isInView ? "animate-fade-in-up" : "opacity-0"
}`}
>
Ready to <span className="text-neon">Power Up</span>?
</h2>
<p
className={`text-cream-dim text-lg max-w-2xl mx-auto ${
isInView ? "animate-fade-in-up" : "opacity-0"
}`}
style={{ animationDelay: "0.1s" }}
>
Let&apos;s talk about making your next festival poop-powered.
Seriously.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
{/* Contact form */}
<div
className={`${isInView ? "animate-fade-in-up" : "opacity-0"}`}
style={{ animationDelay: "0.2s" }}
>
<form
action="mailto:hello@portapower.buzz"
method="POST"
encType="text/plain"
className="space-y-5"
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label
htmlFor="name"
className="block text-sm font-medium text-cream mb-1.5"
>
Name
</label>
<input
type="text"
id="name"
name="name"
required
className="w-full rounded-lg border border-brown-light/40 bg-bg-card px-4 py-3 text-cream placeholder-cream-dim/50 focus:border-neon focus:outline-none focus:ring-1 focus:ring-neon transition-colors"
placeholder="Your name"
/>
</div>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-cream mb-1.5"
>
Email
</label>
<input
type="email"
id="email"
name="email"
required
className="w-full rounded-lg border border-brown-light/40 bg-bg-card px-4 py-3 text-cream placeholder-cream-dim/50 focus:border-neon focus:outline-none focus:ring-1 focus:ring-neon transition-colors"
placeholder="you@festival.com"
/>
</div>
</div>
<div>
<label
htmlFor="festival"
className="block text-sm font-medium text-cream mb-1.5"
>
Festival / Event Name
</label>
<input
type="text"
id="festival"
name="festival"
className="w-full rounded-lg border border-brown-light/40 bg-bg-card px-4 py-3 text-cream placeholder-cream-dim/50 focus:border-neon focus:outline-none focus:ring-1 focus:ring-neon transition-colors"
placeholder="Your festival name"
/>
</div>
<div>
<label
htmlFor="attendees"
className="block text-sm font-medium text-cream mb-1.5"
>
Expected Attendance
</label>
<select
id="attendees"
name="attendees"
className="w-full rounded-lg border border-brown-light/40 bg-bg-card px-4 py-3 text-cream focus:border-neon focus:outline-none focus:ring-1 focus:ring-neon transition-colors"
>
<option value="">Select attendance range</option>
<option value="under-1000">Under 1,000</option>
<option value="1000-5000">1,000 - 5,000</option>
<option value="5000-10000">5,000 - 10,000</option>
<option value="10000-25000">10,000 - 25,000</option>
<option value="25000+">25,000+</option>
</select>
</div>
<div>
<label
htmlFor="message"
className="block text-sm font-medium text-cream mb-1.5"
>
Tell Us About Your Event
</label>
<textarea
id="message"
name="message"
rows={4}
className="w-full rounded-lg border border-brown-light/40 bg-bg-card px-4 py-3 text-cream placeholder-cream-dim/50 focus:border-neon focus:outline-none focus:ring-1 focus:ring-neon transition-colors resize-none"
placeholder="Dates, location, power needs, number of stages..."
/>
</div>
<Button type="submit" size="lg" className="w-full">
<Zap className="h-5 w-5" />
Send Inquiry
</Button>
</form>
</div>
{/* Contact info */}
<div
className={`flex flex-col justify-center gap-8 ${
isInView ? "animate-fade-in-up" : "opacity-0"
}`}
style={{ animationDelay: "0.3s" }}
>
<div>
<h3 className="text-2xl font-bold text-cream mb-4">
Let&apos;s Make Shift Happen
</h3>
<p className="text-cream-dim leading-relaxed">
Whether you&apos;re running a 500-person campout or a 50,000-person
mega-festival, we&apos;ll design a custom poop-to-power solution
that fits your event perfectly. Our team handles everything from
planning to teardown.
</p>
</div>
<div className="space-y-4">
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-neon/10 border border-neon/30">
<Mail className="h-5 w-5 text-neon" />
</div>
<div>
<p className="text-sm text-cream-dim">Email</p>
<p className="font-medium text-cream">
hello@portapower.buzz
</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-neon/10 border border-neon/30">
<Phone className="h-5 w-5 text-neon" />
</div>
<div>
<p className="text-sm text-cream-dim">Phone</p>
<p className="font-medium text-cream">1-800-POO-POWER</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-neon/10 border border-neon/30">
<MapPin className="h-5 w-5 text-neon" />
</div>
<div>
<p className="text-sm text-cream-dim">Headquarters</p>
<p className="font-medium text-cream">
Austin, TX (We deploy nationwide)
</p>
</div>
</div>
</div>
<div className="rounded-xl border border-neon/20 bg-neon/5 p-5">
<p className="text-sm text-neon font-medium mb-1">
Fun fact 💩
</p>
<p className="text-sm text-cream-dim">
A single festival-goer produces enough waste over a 3-day event
to generate roughly 2 kWh of electricity enough to fully
charge a smartphone 100 times.
</p>
</div>
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,80 @@
"use client";
import { useInView } from "@/hooks/use-in-view";
import { AccordionItem } from "@/components/ui/accordion";
const faqs = [
{
question: "Wait, you're actually making electricity from poop?",
answer:
"Yes! It's called anaerobic digestion — a well-established process used in wastewater treatment worldwide. Microorganisms break down organic waste in oxygen-free environments, producing biogas (primarily methane). We capture this biogas and run it through micro-generators to produce clean electricity. The science is solid; we just made it festival-sized and fun.",
},
{
question: "Is it safe? Like... it's poop.",
answer:
"Completely safe. Our bioreactors are sealed, automated systems. The waste is contained in closed tanks, the biogas is processed through standard safety filters, and the electricity output is clean, regulated power. Our systems meet all EPA, OSHA, and local health department requirements. The output is cleaner than diesel generators.",
},
{
question: "How much power can you actually generate?",
answer:
"It depends on the festival size, but a typical 5,000-person weekend festival generates enough waste to power roughly 200-300 kWh — that's enough for main stage lighting, sound for smaller stages, food vendor equipment, and phone charging stations. Larger festivals (10K+) can generate megawatt-scale power.",
},
{
question: "What happens to the waste after the festival?",
answer:
"After biogas extraction, the remaining digestate is a nutrient-rich, pathogen-reduced material that's processed into agricultural fertilizer. Nothing goes to landfill. It's a true circular economy — festival-goers eat, drink, use the facilities, and the output powers the party and feeds next season's crops.",
},
{
question: "Do your porta potties actually look and smell better?",
answer:
"Way better. Because waste is continuously processed (not just sitting in a tank), odor is dramatically reduced. Our units feature active ventilation, hands-free flushing, LED lighting, phone charging ports, and are cleaned on regular rotation by our on-site team. Festival-goers consistently rate them 4.8/5 stars. Yes, we survey people about toilets.",
},
{
question: "How far in advance do we need to book?",
answer:
"We recommend booking at least 3-6 months ahead for large festivals (5K+ attendees) to ensure equipment availability and proper planning. Smaller events can sometimes be accommodated with 4-6 weeks notice. Contact us early — festival season fills up fast, and our poop-powered units are in high demand.",
},
];
export function FAQSection() {
const { ref, isInView } = useInView(0.1);
return (
<section id="faq" className="py-24 px-4" ref={ref}>
<div className="mx-auto max-w-3xl">
<div className="text-center mb-16">
<h2
className={`text-3xl sm:text-4xl md:text-5xl font-bold mb-4 ${
isInView ? "animate-fade-in-up" : "opacity-0"
}`}
>
Frequently <span className="text-neon">Asked</span> Questions
</h2>
<p
className={`text-cream-dim text-lg max-w-2xl mx-auto ${
isInView ? "animate-fade-in-up" : "opacity-0"
}`}
style={{ animationDelay: "0.1s" }}
>
We know you have questions. We&apos;ve heard them all.
</p>
</div>
<div
className={`${isInView ? "animate-fade-in-up" : "opacity-0"}`}
style={{ animationDelay: "0.2s" }}
>
{faqs.map((faq, index) => (
<AccordionItem
key={index}
title={faq.question}
defaultOpen={index === 0}
>
{faq.answer}
</AccordionItem>
))}
</div>
</div>
</section>
);
}

84
components/footer.tsx Normal file
View File

@ -0,0 +1,84 @@
import { Zap } from "lucide-react";
const footerLinks = [
{
title: "Company",
links: [
{ label: "How It Works", href: "#how-it-works" },
{ label: "Services", href: "#services" },
{ label: "Packages", href: "#packages" },
{ label: "Contact", href: "#contact" },
],
},
{
title: "Resources",
links: [
{ label: "FAQ", href: "#faq" },
{ label: "Our Impact", href: "#impact" },
{ label: "Blog", href: "#" },
{ label: "Press Kit", href: "#" },
],
},
{
title: "Legal",
links: [
{ label: "Privacy Policy", href: "#" },
{ label: "Terms of Service", href: "#" },
{ label: "Cookie Policy", href: "#" },
],
},
];
export function Footer() {
return (
<footer className="border-t border-brown-light/20 bg-bg-primary py-16 px-4">
<div className="mx-auto max-w-6xl">
<div className="grid grid-cols-1 md:grid-cols-4 gap-10 mb-12">
{/* Logo & tagline */}
<div className="md:col-span-1">
<a href="#" className="flex items-center gap-2 mb-4">
<Zap className="h-6 w-6 text-neon" />
<span className="text-lg font-bold text-cream">
Porta<span className="text-neon">Power</span>
</span>
</a>
<p className="text-sm text-cream-dim leading-relaxed">
Turning what you leave behind into what powers the party ahead.
</p>
</div>
{/* Link columns */}
{footerLinks.map((group) => (
<div key={group.title}>
<h4 className="font-bold text-cream text-sm mb-4 uppercase tracking-wider">
{group.title}
</h4>
<ul className="space-y-2">
{group.links.map((link) => (
<li key={link.label}>
<a
href={link.href}
className="text-sm text-cream-dim hover:text-neon transition-colors"
>
{link.label}
</a>
</li>
))}
</ul>
</div>
))}
</div>
{/* Bottom bar */}
<div className="border-t border-brown-light/20 pt-8 flex flex-col sm:flex-row items-center justify-between gap-4">
<p className="text-xs text-cream-dim">
&copy; {new Date().getFullYear()} PortaPower Inc. All rights reserved.
</p>
<p className="text-xs text-cream-dim">
Made with 💩 and in Austin, TX
</p>
</div>
</div>
</footer>
);
}

View File

@ -0,0 +1,99 @@
"use client";
import { Button } from "@/components/ui/button";
import { Zap, ArrowDown } from "lucide-react";
const floatingEmojis = [
{ emoji: "💩", top: "15%", left: "8%", delay: "0s", duration: "6s" },
{ emoji: "⚡", top: "25%", right: "12%", delay: "1s", duration: "7s" },
{ emoji: "💩", top: "60%", left: "5%", delay: "2s", duration: "8s" },
{ emoji: "🔋", top: "70%", right: "8%", delay: "0.5s", duration: "6.5s" },
{ emoji: "💩", top: "40%", right: "5%", delay: "3s", duration: "7s" },
{ emoji: "🌿", top: "80%", left: "15%", delay: "1.5s", duration: "9s" },
{ emoji: "⚡", top: "10%", left: "75%", delay: "2.5s", duration: "6s" },
{ emoji: "💩", top: "50%", left: "90%", delay: "0.8s", duration: "7.5s" },
];
export function HeroSection() {
return (
<section className="relative min-h-screen flex items-center justify-center overflow-hidden pt-16">
{/* Neon glow orbs */}
<div className="absolute top-1/4 left-1/4 w-96 h-96 neon-glow-orb animate-pulse-glow" />
<div
className="absolute bottom-1/4 right-1/4 w-80 h-80 neon-glow-orb animate-pulse-glow"
style={{ animationDelay: "1.5s" }}
/>
{/* Floating emojis */}
{floatingEmojis.map((item, i) => (
<span
key={i}
className="absolute text-3xl sm:text-4xl animate-float-slow pointer-events-none select-none opacity-60"
style={{
top: item.top,
left: item.left,
right: item.right,
animationDelay: item.delay,
animationDuration: item.duration,
}}
aria-hidden="true"
>
{item.emoji}
</span>
))}
{/* Content */}
<div className="relative z-10 mx-auto max-w-4xl px-4 text-center">
<div className="animate-fade-in-up">
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-neon/30 bg-neon/10 px-4 py-2 text-sm text-neon">
<Zap className="h-4 w-4" />
<span>The Future of Festival Sanitation</span>
</div>
</div>
<h1
className="animate-fade-in-up text-4xl sm:text-5xl md:text-7xl font-bold leading-tight tracking-tight mb-6"
style={{ animationDelay: "0.1s" }}
>
Turning Festival{" "}
<span className="gradient-text">Waste</span> Into Festival{" "}
<span className="neon-text text-neon">Watts</span>
</h1>
<p
className="animate-fade-in-up mx-auto max-w-2xl text-lg sm:text-xl text-cream-dim mb-10"
style={{ animationDelay: "0.2s" }}
>
Our bioreactor-equipped porta potties don&apos;t just handle the business
&mdash; they turn it into clean electricity. Yes, your festival is
literally powered by poop.
</p>
<div
className="animate-fade-in-up flex flex-col sm:flex-row items-center justify-center gap-4"
style={{ animationDelay: "0.3s" }}
>
<a href="#contact">
<Button size="lg">
<Zap className="h-5 w-5" />
Power Your Festival
</Button>
</a>
<a href="#how-it-works">
<Button variant="outline" size="lg">
See How It Works
</Button>
</a>
</div>
<a
href="#how-it-works"
className="animate-fade-in-up mt-16 inline-block text-cream-dim hover:text-neon transition-colors"
style={{ animationDelay: "0.5s" }}
>
<ArrowDown className="h-6 w-6 animate-bounce" />
</a>
</div>
</section>
);
}

View File

@ -0,0 +1,94 @@
"use client";
import { useInView } from "@/hooks/use-in-view";
import { Droplets, FlaskConical, Zap } from "lucide-react";
const steps = [
{
icon: Droplets,
number: "01",
title: "Collect",
subtitle: "Festival-goers do their thing",
description:
"Our premium porta potties collect organic waste at festivals. Each unit is equipped with smart sensors and pre-processing systems that prepare waste for bioreaction.",
},
{
icon: FlaskConical,
number: "02",
title: "Digest",
subtitle: "Microbes get to work",
description:
"Anaerobic digesters break down organic matter into biogas (methane + CO2). Our proprietary bioreactor tech accelerates this process from weeks to hours.",
},
{
icon: Zap,
number: "03",
title: "Power",
subtitle: "Clean energy flows",
description:
"Biogas fuels micro-generators that produce clean electricity on-site. Power stages, food vendors, lighting — all from what festival-goers flush.",
},
];
export function HowItWorksSection() {
const { ref, isInView } = useInView(0.1);
return (
<section id="how-it-works" className="py-24 px-4" ref={ref}>
<div className="mx-auto max-w-6xl">
<div className="text-center mb-16">
<h2
className={`text-3xl sm:text-4xl md:text-5xl font-bold mb-4 ${
isInView ? "animate-fade-in-up" : "opacity-0"
}`}
>
How <span className="text-neon">It Works</span>
</h2>
<p
className={`text-cream-dim text-lg max-w-2xl mx-auto ${
isInView ? "animate-fade-in-up" : "opacity-0"
}`}
style={{ animationDelay: "0.1s" }}
>
Three simple steps from flush to festival power
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 relative">
{/* Connection lines (desktop only) */}
<div className="hidden md:block absolute top-20 left-[calc(33.33%-1rem)] right-[calc(33.33%-1rem)] h-0.5">
<div className="w-full h-full bg-gradient-to-r from-neon/50 via-neon to-neon/50 rounded-full" />
</div>
{steps.map((step, index) => (
<div
key={step.number}
className={`relative text-center ${
isInView ? "animate-fade-in-up" : "opacity-0"
}`}
style={{ animationDelay: `${0.2 + index * 0.15}s` }}
>
{/* Icon circle */}
<div className="relative mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-bg-card border-2 border-neon/40 neon-border">
<step.icon className="h-8 w-8 text-neon" />
<span className="absolute -top-2 -right-2 flex h-8 w-8 items-center justify-center rounded-full bg-neon text-bg-primary text-xs font-bold">
{step.number}
</span>
</div>
<h3 className="text-2xl font-bold text-cream mb-1">
{step.title}
</h3>
<p className="text-neon text-sm font-medium mb-3">
{step.subtitle}
</p>
<p className="text-cream-dim leading-relaxed">
{step.description}
</p>
</div>
))}
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,103 @@
"use client";
import { useInView } from "@/hooks/use-in-view";
import { useCountUp } from "@/hooks/use-count-up";
import { Leaf, Zap, Recycle, Music } from "lucide-react";
const stats = [
{
icon: Recycle,
value: 2.5,
suffix: "M lbs",
label: "Waste Diverted from Landfill",
decimals: 1,
},
{
icon: Zap,
value: 850,
suffix: " MWh",
label: "Clean Energy Generated",
decimals: 0,
},
{
icon: Leaf,
value: 1200,
suffix: " tons",
label: "CO2 Emissions Saved",
decimals: 0,
},
{
icon: Music,
value: 500,
suffix: "+",
label: "Festivals Powered",
decimals: 0,
},
];
function StatCard({
stat,
isInView,
delay,
}: {
stat: (typeof stats)[0];
isInView: boolean;
delay: number;
}) {
const count = useCountUp(stat.value, isInView, 2500, stat.decimals);
return (
<div
className={`text-center ${isInView ? "animate-fade-in-up" : "opacity-0"}`}
style={{ animationDelay: `${delay}s` }}
>
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-neon/10 border border-neon/30">
<stat.icon className="h-7 w-7 text-neon" />
</div>
<div className="text-4xl sm:text-5xl font-bold text-neon neon-text mb-2">
{count}
{stat.suffix}
</div>
<p className="text-cream-dim text-sm">{stat.label}</p>
</div>
);
}
export function ImpactSection() {
const { ref, isInView } = useInView(0.15);
return (
<section id="impact" className="py-24 px-4" ref={ref}>
<div className="mx-auto max-w-6xl">
<div className="text-center mb-16">
<h2
className={`text-3xl sm:text-4xl md:text-5xl font-bold mb-4 ${
isInView ? "animate-fade-in-up" : "opacity-0"
}`}
>
Our <span className="text-neon">Impact</span>
</h2>
<p
className={`text-cream-dim text-lg max-w-2xl mx-auto ${
isInView ? "animate-fade-in-up" : "opacity-0"
}`}
style={{ animationDelay: "0.1s" }}
>
Real numbers from real festivals. Every flush makes a difference.
</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 md:gap-12">
{stats.map((stat, index) => (
<StatCard
key={stat.label}
stat={stat}
isInView={isInView}
delay={0.2 + index * 0.1}
/>
))}
</div>
</div>
</section>
);
}

95
components/navigation.tsx Normal file
View File

@ -0,0 +1,95 @@
"use client";
import { useState, useEffect } from "react";
import { Menu, X, Zap } from "lucide-react";
import { Button } from "@/components/ui/button";
const navLinks = [
{ href: "#how-it-works", label: "How It Works" },
{ href: "#services", label: "Services" },
{ href: "#impact", label: "Impact" },
{ href: "#packages", label: "Packages" },
{ href: "#faq", label: "FAQ" },
{ href: "#contact", label: "Contact" },
];
export function Navigation() {
const [isOpen, setIsOpen] = useState(false);
const [isScrolled, setIsScrolled] = useState(false);
useEffect(() => {
const handleScroll = () => setIsScrolled(window.scrollY > 50);
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return (
<nav
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
isScrolled
? "bg-bg-primary/95 backdrop-blur-md shadow-lg shadow-black/20"
: "bg-transparent"
}`}
>
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex h-16 items-center justify-between">
{/* Logo */}
<a href="#" className="flex items-center gap-2 group">
<Zap className="h-7 w-7 text-neon group-hover:animate-wiggle" />
<span className="text-xl font-bold text-cream">
Porta<span className="text-neon">Power</span>
</span>
</a>
{/* Desktop nav */}
<div className="hidden md:flex items-center gap-6">
{navLinks.map((link) => (
<a
key={link.href}
href={link.href}
className="text-sm font-medium text-cream-dim hover:text-neon transition-colors"
>
{link.label}
</a>
))}
<a href="#contact">
<Button size="sm">Get a Quote</Button>
</a>
</div>
{/* Mobile hamburger */}
<button
className="md:hidden text-cream hover:text-neon transition-colors cursor-pointer"
onClick={() => setIsOpen(!isOpen)}
aria-label="Toggle menu"
>
{isOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
</button>
</div>
{/* Mobile menu */}
{isOpen && (
<div className="md:hidden pb-4 animate-fade-in">
<div className="flex flex-col gap-3 pt-2">
{navLinks.map((link) => (
<a
key={link.href}
href={link.href}
className="text-sm font-medium text-cream-dim hover:text-neon transition-colors py-2"
onClick={() => setIsOpen(false)}
>
{link.label}
</a>
))}
<a href="#contact" onClick={() => setIsOpen(false)}>
<Button size="sm" className="w-full mt-2">
Get a Quote
</Button>
</a>
</div>
</div>
)}
</div>
</nav>
);
}

View File

@ -0,0 +1,140 @@
"use client";
import { useInView } from "@/hooks/use-in-view";
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card";
import { Check, Star } from "lucide-react";
const packages = [
{
name: "The Outhouse",
subtitle: "For intimate gatherings",
price: "From $5K",
capacity: "Up to 1,000 attendees",
featured: false,
features: [
"10-20 premium porta potties",
"Basic bioreactor unit",
"Up to 50 kWh generation",
"Standard maintenance",
"Setup & teardown included",
"Basic power distribution",
],
},
{
name: "The Throne Room",
subtitle: "Most popular for mid-size festivals",
price: "From $25K",
capacity: "Up to 10,000 attendees",
featured: true,
features: [
"50-100 premium porta potties",
"Multi-reactor array",
"Up to 500 kWh generation",
"24/7 dedicated maintenance",
"Full power infrastructure",
"Real-time monitoring dashboard",
"VIP luxury units available",
"Carbon offset certificate",
],
},
{
name: "The Royal Flush",
subtitle: "For major festivals & events",
price: "Custom",
capacity: "10,000+ attendees",
featured: false,
features: [
"100+ premium porta potties",
"Industrial bioreactor farm",
"Megawatt-scale generation",
"Dedicated on-site team",
"Full grid integration",
"Real-time public dashboard",
"VIP & ADA luxury suites",
"PR & sustainability report",
],
},
];
export function PackagesSection() {
const { ref, isInView } = useInView(0.1);
return (
<section id="packages" className="py-24 px-4 bg-brown-dark/30" ref={ref}>
<div className="mx-auto max-w-6xl">
<div className="text-center mb-16">
<h2
className={`text-3xl sm:text-4xl md:text-5xl font-bold mb-4 ${
isInView ? "animate-fade-in-up" : "opacity-0"
}`}
>
Pick Your <span className="text-neon">Package</span>
</h2>
<p
className={`text-cream-dim text-lg max-w-2xl mx-auto ${
isInView ? "animate-fade-in-up" : "opacity-0"
}`}
style={{ animationDelay: "0.1s" }}
>
From cozy campouts to massive music festivals, we&apos;ve got you covered
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 items-stretch">
{packages.map((pkg, index) => (
<Card
key={pkg.name}
className={`relative flex flex-col card-hover ${
pkg.featured
? "border-neon/50 neon-border md:scale-105"
: ""
} ${isInView ? "animate-fade-in-up" : "opacity-0"}`}
style={{ animationDelay: `${0.2 + index * 0.1}s` }}
>
{pkg.featured && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2 flex items-center gap-1 rounded-full bg-neon px-4 py-1 text-xs font-bold text-bg-primary">
<Star className="h-3 w-3" />
Most Popular
</div>
)}
<CardHeader className="text-center">
<CardTitle className="text-2xl">{pkg.name}</CardTitle>
<CardDescription>{pkg.subtitle}</CardDescription>
<div className="mt-4">
<span className="text-4xl font-bold text-neon">
{pkg.price}
</span>
</div>
<p className="text-xs text-cream-dim mt-1">{pkg.capacity}</p>
</CardHeader>
<CardContent className="flex-1">
<ul className="space-y-3">
{pkg.features.map((feature) => (
<li
key={feature}
className="flex items-start gap-2 text-sm text-cream-dim"
>
<Check className="h-4 w-4 text-neon shrink-0 mt-0.5" />
{feature}
</li>
))}
</ul>
</CardContent>
<CardFooter className="mt-auto">
<a href="#contact" className="w-full">
<Button
variant={pkg.featured ? "default" : "outline"}
className="w-full"
>
Get Started
</Button>
</a>
</CardFooter>
</Card>
))}
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,103 @@
"use client";
import { useInView } from "@/hooks/use-in-view";
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import { Toilet, FlaskConical, Trash2, Cable } from "lucide-react";
const services = [
{
icon: Toilet,
title: "Premium Porta Potties",
description:
"Not your grandpa's porta john. Our units feature hands-free flush, LED lighting, ventilation, and phone charging ports. They're practically luxury.",
features: ["Hands-free flush", "LED ambient lighting", "USB charging", "Ventilation fans"],
},
{
icon: FlaskConical,
title: "Bioreactor Power",
description:
"On-site anaerobic digesters convert waste to biogas in hours, not weeks. Our micro-generators produce clean, reliable electricity for your event.",
features: ["Rapid biogas conversion", "Micro-generator arrays", "Real-time monitoring", "Scalable output"],
},
{
icon: Trash2,
title: "Waste Management",
description:
"Full-service waste handling from setup to teardown. We manage collection, processing, and disposal so you can focus on the festival vibes.",
features: ["Setup & teardown", "24/7 maintenance", "Eco-friendly processing", "Zero landfill goal"],
},
{
icon: Cable,
title: "Power Infrastructure",
description:
"Complete power distribution for stages, vendors, and facilities. Our grid integrates bio-power with backup systems for uninterrupted festival energy.",
features: ["Distribution panels", "Load balancing", "Backup integration", "Smart monitoring"],
},
];
export function ServicesSection() {
const { ref, isInView } = useInView(0.1);
return (
<section id="services" className="py-24 px-4 bg-brown-dark/30" ref={ref}>
<div className="mx-auto max-w-6xl">
<div className="text-center mb-16">
<h2
className={`text-3xl sm:text-4xl md:text-5xl font-bold mb-4 ${
isInView ? "animate-fade-in-up" : "opacity-0"
}`}
>
Our <span className="text-neon">Services</span>
</h2>
<p
className={`text-cream-dim text-lg max-w-2xl mx-auto ${
isInView ? "animate-fade-in-up" : "opacity-0"
}`}
style={{ animationDelay: "0.1s" }}
>
Everything you need to power your festival sustainably
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{services.map((service, index) => (
<Card
key={service.title}
className={`card-hover ${
isInView ? "animate-fade-in-up" : "opacity-0"
}`}
style={{ animationDelay: `${0.2 + index * 0.1}s` }}
>
<CardHeader>
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-neon/10 border border-neon/30">
<service.icon className="h-6 w-6 text-neon" />
</div>
<div>
<CardTitle>{service.title}</CardTitle>
</div>
</div>
<CardDescription className="mt-2">
{service.description}
</CardDescription>
</CardHeader>
<CardContent>
<ul className="grid grid-cols-2 gap-2">
{service.features.map((feature) => (
<li
key={feature}
className="flex items-center gap-2 text-sm text-cream-dim"
>
<span className="h-1.5 w-1.5 rounded-full bg-neon shrink-0" />
{feature}
</li>
))}
</ul>
</CardContent>
</Card>
))}
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,97 @@
"use client";
import { useInView } from "@/hooks/use-in-view";
import { Card, CardContent } from "@/components/ui/card";
import { Quote } from "lucide-react";
const testimonials = [
{
quote:
"I told my board we'd be powering the main stage with poop. They laughed. Then they saw the energy bill savings. Nobody's laughing now — well, actually we all are.",
name: "Sarah Chen",
role: "Director, SunBurst Music Festival",
},
{
quote:
"Our attendees kept asking where the generators were. When we told them the electricity was coming from the toilets, it became the most-shared fact on social media all weekend.",
name: "Marcus Johnson",
role: "Operations Lead, GreenFields Festival",
},
{
quote:
"PortaPower's units are the cleanest festival bathrooms I've ever seen. The fact that they also power our food court is just... *chef's kiss*. Literally.",
name: "Priya Patel",
role: "Founder, EchoVibe Music Fest",
},
{
quote:
"We went from 14 diesel generators to 3 after bringing PortaPower on. The noise reduction alone was worth it. The sustainability angle made our sponsors ecstatic.",
name: "Jake Morrison",
role: "Festival Manager, ToneFest",
},
];
export function TestimonialsSection() {
const { ref, isInView } = useInView(0.1);
return (
<section className="py-24 px-4" ref={ref}>
<div className="mx-auto max-w-6xl">
<div className="text-center mb-16">
<h2
className={`text-3xl sm:text-4xl md:text-5xl font-bold mb-4 ${
isInView ? "animate-fade-in-up" : "opacity-0"
}`}
>
What Festival Organizers{" "}
<span className="text-neon">Say</span>
</h2>
<p
className={`text-cream-dim text-lg max-w-2xl mx-auto ${
isInView ? "animate-fade-in-up" : "opacity-0"
}`}
style={{ animationDelay: "0.1s" }}
>
Don&apos;t just take our word for it (but seriously, it&apos;s
poop-powered electricity how cool is that?)
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{testimonials.map((testimonial, index) => (
<Card
key={testimonial.name}
className={`card-hover ${
isInView ? "animate-fade-in-up" : "opacity-0"
}`}
style={{ animationDelay: `${0.2 + index * 0.1}s` }}
>
<CardContent className="pt-6">
<Quote className="h-8 w-8 text-neon/40 mb-4" />
<p className="text-cream-dim leading-relaxed mb-6 italic">
&ldquo;{testimonial.quote}&rdquo;
</p>
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-neon/20 flex items-center justify-center text-neon font-bold text-sm">
{testimonial.name
.split(" ")
.map((n) => n[0])
.join("")}
</div>
<div>
<p className="font-bold text-cream text-sm">
{testimonial.name}
</p>
<p className="text-cream-dim text-xs">
{testimonial.role}
</p>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,51 @@
"use client";
import * as React from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
interface AccordionItemProps {
title: string;
children: React.ReactNode;
defaultOpen?: boolean;
}
export function AccordionItem({
title,
children,
defaultOpen = false,
}: AccordionItemProps) {
const [isOpen, setIsOpen] = React.useState(defaultOpen);
const contentRef = React.useRef<HTMLDivElement>(null);
return (
<div className="border-b border-brown-light/30">
<button
className="flex w-full items-center justify-between py-5 text-left font-bold text-cream transition-colors hover:text-neon cursor-pointer"
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
>
{title}
<ChevronDown
className={cn(
"h-5 w-5 shrink-0 text-neon transition-transform duration-300",
isOpen && "rotate-180"
)}
/>
</button>
<div
ref={contentRef}
className="overflow-hidden transition-all duration-300"
style={{
maxHeight: isOpen
? contentRef.current?.scrollHeight
? `${contentRef.current.scrollHeight}px`
: "500px"
: "0px",
}}
>
<div className="pb-5 text-cream-dim leading-relaxed">{children}</div>
</div>
</div>
);
}

48
components/ui/button.tsx Normal file
View File

@ -0,0 +1,48 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg font-bold transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neon focus-visible:ring-offset-2 focus-visible:ring-offset-bg-primary disabled:pointer-events-none disabled:opacity-50 cursor-pointer",
{
variants: {
variant: {
default:
"bg-neon text-bg-primary hover:bg-neon-dim hover:shadow-[0_0_20px_rgba(57,255,20,0.4)]",
outline:
"border-2 border-neon text-neon hover:bg-neon/10 hover:shadow-[0_0_15px_rgba(57,255,20,0.2)]",
ghost: "text-cream hover:bg-cream/10",
},
size: {
default: "h-11 px-6 py-2 text-sm",
sm: "h-9 px-4 text-sm",
lg: "h-14 px-8 text-base",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

71
components/ui/card.tsx Normal file
View File

@ -0,0 +1,71 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-2xl border border-brown-light/30 bg-bg-card p-6 shadow-lg",
className
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col gap-2 pb-4", className)} {...props} />
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("text-xl font-bold text-cream", className)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-cream-dim", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center pt-4", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };

35
docker-compose.yml Normal file
View File

@ -0,0 +1,35 @@
services:
portapower-prod:
build:
context: .
dockerfile: Dockerfile
image: portapower-prod:latest
container_name: portapower-prod
restart: unless-stopped
read_only: true
tmpfs:
- /tmp
- /var/cache/nginx
- /var/run
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
- CHOWN
- SETGID
- SETUID
- DAC_OVERRIDE
security_opt:
- no-new-privileges:true
networks:
- traefik-public
labels:
- "traefik.enable=true"
- "traefik.http.routers.portapower.rule=Host(`portapower.buzz`) || Host(`www.portapower.buzz`)"
- "traefik.http.routers.portapower.entrypoints=web"
- "traefik.http.services.portapower.loadbalancer.server.port=80"
- "traefik.docker.network=traefik-public"
networks:
traefik-public:
external: true

37
hooks/use-count-up.ts Normal file
View File

@ -0,0 +1,37 @@
"use client";
import { useEffect, useState } from "react";
export function useCountUp(
target: number,
isActive: boolean,
duration = 2000,
decimals = 0
) {
const [count, setCount] = useState(0);
useEffect(() => {
if (!isActive) return;
const startTime = Date.now();
const startValue = 0;
const tick = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
// Ease out cubic
const eased = 1 - Math.pow(1 - progress, 3);
const current = startValue + (target - startValue) * eased;
setCount(Number(current.toFixed(decimals)));
if (progress < 1) {
requestAnimationFrame(tick);
}
};
requestAnimationFrame(tick);
}, [isActive, target, duration, decimals]);
return count;
}

28
hooks/use-in-view.ts Normal file
View File

@ -0,0 +1,28 @@
"use client";
import { useEffect, useRef, useState } from "react";
export function useInView(threshold = 0.2) {
const ref = useRef<HTMLDivElement>(null);
const [isInView, setIsInView] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsInView(true);
observer.unobserve(element);
}
},
{ threshold }
);
observer.observe(element);
return () => observer.disconnect();
}, [threshold]);
return { ref, isInView };
}

6
lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

10
next.config.ts Normal file
View File

@ -0,0 +1,10 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
images: {
unoptimized: true,
},
};
export default nextConfig;

39
nginx.conf Normal file
View File

@ -0,0 +1,39 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Enable gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml+rss image/svg+xml;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Handle Next.js static export routes
location / {
try_files $uri $uri.html $uri/index.html /index.html;
}
# Cache static assets (Next.js hashed files)
location /_next/static/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Cache other static files
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000";
}
# Custom error pages
error_page 404 /404.html;
error_page 500 502 503 504 /500.html;
}

1695
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "portapower-website",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"lucide-react": "^0.468.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"tailwind-merge": "^2.6.0"
},
"devDependencies": {
"@types/node": "^22.10.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.7.0",
"@tailwindcss/postcss": "^4.0.0",
"tailwindcss": "^4.0.0"
}
}

8
postcss.config.mjs Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

23
tsconfig.json Normal file
View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}