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:
commit
f45d2df4ec
|
|
@ -0,0 +1,8 @@
|
|||
node_modules
|
||||
.next
|
||||
out
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
.env*
|
||||
*.md
|
||||
|
|
@ -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
|
||||
|
|
@ -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;"]
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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'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's Make Shift Happen
|
||||
</h3>
|
||||
<p className="text-cream-dim leading-relaxed">
|
||||
Whether you're running a 500-person campout or a 50,000-person
|
||||
mega-festival, we'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
© {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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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't just handle the business
|
||||
— 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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't just take our word for it (but seriously, it'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">
|
||||
“{testimonial.quote}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "export",
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
@ -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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Reference in New Issue