commit ec0ef0142722e58f29090cf8b4c5ecc3d6b954e2 Author: user Date: Sun Dec 7 16:07:03 2025 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..16d54bb --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# build output +dist/ +# generated types +.astro/ + +# dependencies +node_modules/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + + +# environment variables +.env +.env.production + +# macOS-specific files +.DS_Store + +# jetbrains setting folder +.idea/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..22a1505 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,4 @@ +{ + "recommendations": ["astro-build.astro-vscode"], + "unwantedRecommendations": [] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..d642209 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "command": "./node_modules/.bin/astro dev", + "name": "Development server", + "request": "launch", + "type": "node-terminal" + } + ] +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..da6d683 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM oven/bun:1.1 AS builder + +WORKDIR /app + +COPY package.json bun.lockb ./ + +RUN bun install + +COPY . . + +RUN bun run build + +EXPOSE 80 + +CMD ["bun", "run", "start"] diff --git a/astro.config.mjs b/astro.config.mjs new file mode 100644 index 0000000..16777d2 --- /dev/null +++ b/astro.config.mjs @@ -0,0 +1,14 @@ +import { defineConfig } from "astro/config"; +import react from "@astrojs/react"; +import adapter from "@nurodev/astro-bun"; +import tailwind from "@tailwindcss/vite"; + +export default defineConfig({ + base: "/", + adapter: adapter(), + output: "server", + vite: { + plugins: [tailwind()], + }, + integrations: [react({ experimentalDisableStreaming: true })], +}); diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..6a5bae4 Binary files /dev/null and b/bun.lockb differ diff --git a/components.json b/components.json new file mode 100644 index 0000000..7c35d68 --- /dev/null +++ b/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.mjs", + "css": "src/globals.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..36a3815 --- /dev/null +++ b/package.json @@ -0,0 +1,56 @@ +{ + "name": "netfundable", + "type": "module", + "version": "0.0.1", + "scripts": { + "dev": "astro dev", + "obuild": "rm -rf dist && astro check && astro build", + "start": "HOST=0.0.0.0 PORT=80 bun run ./dist/server/entry.mjs", + "build": "astro build", + "preview": "astro preview", + "astro": "astro" + }, + "dependencies": { + "@astrojs/check": "0.9.4", + "@astrojs/cloudflare": "12.4.0", + "@astrojs/mdx": "4.2.3", + "@astrojs/react": "4.2.3", + "@astrojs/svelte": "7.0.9", + "@astrojs/tailwind": "6.0.2", + "@hookform/resolvers": "^3.9.0", + "@nurodev/astro-bun": "^2.0.5", + "@radix-ui/react-alert-dialog": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.1", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-slot": "^1.1.0", + "@tailwindcss/typography": "^0.5.13", + "@tailwindcss/vite": "^4.1.3", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "aos": "^2.3.4", + "astro": "5.6.1", + "caniuse-lite": "^1.0.30001706", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "embla-carousel-react": "^8.1.6", + "lucide-react": "^0.483.0", + "next-themes": "^0.3.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-hook-form": "^7.52.1", + "sonner": "^1.5.0", + "svelte": "^4.2.18", + "tailwind-merge": "^2.3.0", + "tailwindcss": "^4.1.3", + "tailwindcss-animate": "^1.0.7", + "typescript": "^5.5.3" + }, + "devDependencies": { + "@types/aos": "^3.0.7", + "astro-icon": "^1.1.0", + "prettier": "^3.3.2", + "prettier-plugin-astro": "^0.14.0", + "prettier-plugin-tailwindcss": "^0.6.11" + } +} diff --git a/prettier.config.cjs b/prettier.config.cjs new file mode 100644 index 0000000..e26cb3e --- /dev/null +++ b/prettier.config.cjs @@ -0,0 +1,10 @@ +/** @type {import("prettier").Config & { [key:string]: any }} */ +module.exports = { + arrowParens: "always", + singleQuote: false, + jsxSingleQuote: false, + semi: true, + trailingComma: "all", + tabWidth: 4, + plugins: ["prettier-plugin-tailwindcss", "prettier-plugin-astro"], +}; diff --git a/public/assets/about-image.png b/public/assets/about-image.png new file mode 100644 index 0000000..6351b3a Binary files /dev/null and b/public/assets/about-image.png differ diff --git a/public/assets/hero-image.png b/public/assets/hero-image.png new file mode 100644 index 0000000..af0ead0 Binary files /dev/null and b/public/assets/hero-image.png differ diff --git a/public/assets/logo.svg b/public/assets/logo.svg new file mode 100644 index 0000000..9f55a49 --- /dev/null +++ b/public/assets/logo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/assets/map.png b/public/assets/map.png new file mode 100644 index 0000000..3a9c93f Binary files /dev/null and b/public/assets/map.png differ diff --git a/public/assets/optimum-banner.png b/public/assets/optimum-banner.png new file mode 100644 index 0000000..eaef7f9 Binary files /dev/null and b/public/assets/optimum-banner.png differ diff --git a/public/assets/optimum.png b/public/assets/optimum.png new file mode 100644 index 0000000..be77e4a Binary files /dev/null and b/public/assets/optimum.png differ diff --git a/public/assets/spectrum-banner.png b/public/assets/spectrum-banner.png new file mode 100644 index 0000000..29c25c3 Binary files /dev/null and b/public/assets/spectrum-banner.png differ diff --git a/public/assets/spectrum.png b/public/assets/spectrum.png new file mode 100644 index 0000000..ab0d610 Binary files /dev/null and b/public/assets/spectrum.png differ diff --git a/public/assets/testimonial-lady.png b/public/assets/testimonial-lady.png new file mode 100644 index 0000000..3ce0e13 Binary files /dev/null and b/public/assets/testimonial-lady.png differ diff --git a/public/assets/verizon-banner.png b/public/assets/verizon-banner.png new file mode 100644 index 0000000..c995d6f Binary files /dev/null and b/public/assets/verizon-banner.png differ diff --git a/public/assets/verizon.png b/public/assets/verizon.png new file mode 100644 index 0000000..03bf613 Binary files /dev/null and b/public/assets/verizon.png differ diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..70446e7 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/fonts/sen-variable.ttf b/public/fonts/sen-variable.ttf new file mode 100644 index 0000000..57ac774 Binary files /dev/null and b/public/fonts/sen-variable.ttf differ diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 0000000..8b6aa00 --- /dev/null +++ b/public/logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/CallNowDialog.tsx b/src/components/CallNowDialog.tsx new file mode 100644 index 0000000..63ed5ff --- /dev/null +++ b/src/components/CallNowDialog.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { cn } from "@/lib/utils"; +import { + getPhoneNumber, + getContactLink, + TRANSITION_ALL, +} from "@/lib/constants"; +import { PhoneCall } from "lucide-react"; +import Title from "./atoms/title"; + +interface CallNowDialogProps { + page?: string; +} + +function CallNowDialog({ page }: CallNowDialogProps) { + const [open, setOpen] = React.useState(false); + const phoneNumber = getPhoneNumber(page); + const contactLink = getContactLink(page); + + React.useEffect(() => { + setOpen(true); + }, []); + + return open ? ( +
setOpen(false)} + > +
+ + + <a + className={cn( + "flex items-center gap-4 rounded-full bg-lime-700 px-8 py-6 text-lime-50", + )} + href={contactLink} + > + <PhoneCall className="h-8 w-auto" /> + <p className="text-xl font-semibold">{phoneNumber}</p> + </a> + </div> + </div> + ) : ( + <></> + ); +} + +export default CallNowDialog; diff --git a/src/components/HeroSection.astro b/src/components/HeroSection.astro new file mode 100644 index 0000000..702eb50 --- /dev/null +++ b/src/components/HeroSection.astro @@ -0,0 +1,77 @@ +--- +import MaxWidthWrapper from "./other/max.width.wrapper"; +import GridPattern from "@/components/atoms/grid.pattern"; +import Title from "@/components/atoms/title"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { CONTACT_LINK } from "@/lib/constants"; +import { Image } from "astro:assets"; +--- + +<div class="flex flex-col gap-8"> + <div + class="min-h-screen h-full w-full bg-lime-900 pt-32 grid place-items-center pb-32 md:pb-0" + > + <GridPattern + width={30} + height={30} + x={-1} + y={-1} + strokeDasharray={"4 2"} + className={cn( + "[mask-image:radial-gradient(80vw_circle_at_center,white,transparent)] z-0", + )} + /> + <MaxWidthWrapper + className="grid h-full w-full grid-cols-1 place-items-center md:grid-cols-2 gap-20 lg:gap-8 pb-24 md:pb-0 z-10" + > + <div + class="flex flex-col gap-8 w-full" + data-aos="zoom-in" + data-aos-duration="400" + > + <div + class="flex flex-col gap-2 self-center max-w-md md:max-w-none" + > + <h6 class="font-semibold text-lime-400 text-lg"> + NO CONTRACTS | 30-DAY MONEY-BACK GUARANTEE + </h6> + <Title + title="Get Spectrum Internet®, TV & Voice for the Best Price" + color="white" + size="h1" + /> + </div> + <div class="flex flex-col sm:flex-row gap-4"> + <a href={CONTACT_LINK}> + <Button + size={"lg"} + className="w-full sm:w-max" + variant="lime" + > + Call Now + </Button> + </a> + <a href={"/#packages"}> + <Button + size={"lg"} + className="w-full sm:w-max" + variant={"muted"} + > + View Packages + </Button> + </a> + </div> + </div> + <Image + src={"/assets/hero-image.png"} + alt="Spectrum services illustration" + class={"w-full h-full object-contain max-w-lg lg:max-w-none"} + width={300} + height={500} + data-aos="fade-up" + data-aos-duration="500" + /> + </MaxWidthWrapper> + </div> +</div> diff --git a/src/components/OurPartners.astro b/src/components/OurPartners.astro new file mode 100644 index 0000000..0cbfc1f --- /dev/null +++ b/src/components/OurPartners.astro @@ -0,0 +1,14 @@ +--- +import MaxWidthWrapper from "./other/max.width.wrapper"; +import SectionHeading from "./atoms/section.heading"; +import Partners from "./Partners.tsx"; +--- + +<MaxWidthWrapper id="our-partners" className="w-full space-y-20"> + <SectionHeading + title="We are authorized retailers these amazing providers" + subtitle="Our partners" + /> + + <Partners /> +</MaxWidthWrapper> diff --git a/src/components/Packages.astro b/src/components/Packages.astro new file mode 100644 index 0000000..647baaa --- /dev/null +++ b/src/components/Packages.astro @@ -0,0 +1,116 @@ +--- +import MaxWidthWrapper from "./other/max.width.wrapper"; +import SectionHeading from "./atoms/section.heading"; +import PricingCard from "@/components/molecules/pricing.card.astro"; +import Title from "./atoms/title"; +import { CONTACT_LINK } from "@/lib/constants"; +import CallButton from "@/components/molecules/CallButton"; +import { cn } from "@/lib/utils"; + +const packages1 = [ + { + title: "Triple Play SELECT", + price: "99.97", + description: "Perfect for casual streaming and everyday entertainment.", + features: [ + "125+ HD Channels", + "Up to 300 Mbps Internet Speed", + "FREE Spectrum TV® App", + "FREE HD & 40,000+ On Demand titles", + "FREE Internet modem & antivirus", + "Unlimited nationwide calling", + "28 premium calling features", + "No data caps or speed throttling", + ], + aos: { + "data-aos": "fade-up", + "data-aos-duration": "400", + }, + }, + { + title: "Triple Play SILVER", + price: "124.97", + description: + "Ideal for multi-device households and premium entertainment.", + features: [ + "175+ HD Channels including HBO Max™", + "Up to 500 Mbps Internet Speed", + "FREE Spectrum TV® App with Cloud DVR", + "FREE HD & 65,000+ On Demand titles", + "Advanced WiFi router included", + "Enhanced Internet security suite", + "Unlimited nationwide & Mexico calling", + "Priority customer support 24/7", + ], + aos: { + "data-aos": "fade-up", + "data-aos-duration": "600", + }, + isMostPopular: true, + }, + { + title: "Triple Play GOLD", + price: "144.97", + description: "Ultimate entertainment package for power users.", + features: [ + "200+ HD Channels + Premium Networks", + "Up to 1 GIG Internet Speed", + "FREE Spectrum TV® App with Extended DVR", + "FREE HD & 85,000+ On Demand titles", + "WiFi 6 router & range extender", + "Premium security with parental controls", + "Global unlimited calling to 30+ countries", + "VIP technical support & installation", + ], + aos: { + "data-aos": "fade-up", + "data-aos-duration": "800", + }, + }, +]; + +const { page, variant = "lime" } = Astro.props; + +const bgColor = "bg-lime-50"; +--- + +<div class={cn("w-full h-full py-32", bgColor)}> + <MaxWidthWrapper id="packages" className="w-full space-y-20"> + <div class="grid place-items-center gap-2"> + <SectionHeading + title="Choose the perfect plan for your home" + subtitle="Our Packages" + /> + <span class="text-gray-500">NO hidden fees. NO contracts.</span> + </div> + + <div class="grid place-items-center w-full gap-8 text-center"> + <Title title="Internet Speeds" size="h2" color="primary" /> + + <div + class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-8" + > + { + packages1.map((item) => ( + <PricingCard + {...item} + color={variant} + ctaLink={CONTACT_LINK} + page={page} + /> + )) + } + </div> + </div> + + <div class="grid place-items-center w-full gap-8 text-center"> + <Title + title="Still not what you're looking for? Let's make you a package!" + size="h2" + color="primary" + /> + + <CallButton client:load variant={variant} page={page} /> + </div> + </MaxWidthWrapper> +</div> diff --git a/src/components/Partners.tsx b/src/components/Partners.tsx new file mode 100644 index 0000000..c93a82c --- /dev/null +++ b/src/components/Partners.tsx @@ -0,0 +1,32 @@ +import { contactLinks } from "@/lib/constants"; + +function Partners() { + return ( + <div className="flex w-full flex-col items-center justify-center gap-8 rounded-xl p-8 md:flex-row md:flex-wrap md:justify-around"> + <a + href={contactLinks.spectrum} + data-aos="zoom-in-left" + data-aos-duration="500" + > + <img + src={"/assets/spectrum.png"} + alt="Spectrum" + className={"h-24 w-auto object-contain md:h-20"} + /> + </a> + <a + href={contactLinks.optimum} + data-aos="zoom-in" + data-aos-duration="500" + > + <img + src={"/assets/optimum.png"} + alt="Optimum" + className={"h-24 w-auto object-contain md:h-20"} + /> + </a> + </div> + ); +} + +export default Partners; diff --git a/src/components/ServicesSection.astro b/src/components/ServicesSection.astro new file mode 100644 index 0000000..0dd919b --- /dev/null +++ b/src/components/ServicesSection.astro @@ -0,0 +1,55 @@ +--- +import MaxWidthWrapper from "./other/max.width.wrapper"; +import SectionHeading from "./atoms/section.heading"; +import { cn } from "@/lib/utils"; +import Title from "./atoms/title"; + +const { variant = "lime", services } = Astro.props; + +const colorMap = + variant === "lime" + ? { + primary: "bg-lime-100 text-lime-600", + secondary: "bg-lime-100 text-lime-700", + tertiary: "bg-lime-100 text-lime-600", + quaternary: "bg-lime-100 text-lime-700", + } + : { + primary: "bg-amber-100 text-amber-600", + secondary: "bg-amber-100 text-amber-700", + tertiary: "bg-amber-100 text-amber-600", + quaternary: "bg-amber-100 text-amber-700", + }; +--- + +<MaxWidthWrapper id="services" className="w-full space-y-20"> + <SectionHeading + title="Everything you need in one package" + subtitle="Our Services" + /> + + <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8 lg:gap-12"> + { + services.map((service) => ( + <div + class="flex items-center text-center flex-col gap-4" + {...service.aos} + > + <div + class={cn( + "grid place-items-center w-20 h-20 rounded-full", + // @ts-ignore + colorMap[service.color], + )} + > + <service.icon className="h-8 w-auto" /> + </div> + <Title title={service.title} size="h3" /> + <span class="text-sm text-gray-700"> + {service.description} + </span> + </div> + )) + } + </div> +</MaxWidthWrapper> diff --git a/src/components/Stats.astro b/src/components/Stats.astro new file mode 100644 index 0000000..34bd467 --- /dev/null +++ b/src/components/Stats.astro @@ -0,0 +1,67 @@ +--- +import MaxWidthWrapper from "./other/max.width.wrapper"; +import { cn } from "@/lib/utils"; +import Title from "./atoms/title"; +import { Image } from "astro:assets"; + +const stats = [ + { id: 0, title: "Happy Clients", count: "32,800+" }, + { id: 1, title: "Satisfaction", count: "100%" }, + { id: 2, title: "Years of Experience", count: "8+" }, +]; +--- + +<MaxWidthWrapper + id="stats" + className="grid h-full w-full grid-cols-1 place-items-center md:grid-cols-2 gap-20 lg:gap-8" +> + <div + class="flex flex-col gap-8" + data-aos="fade-right" + data-aos-durationn="400" + > + <div class="flex flex-col gap-2"> + <Title + title="Reliable and Rapid Connectivity Nationwide" + color="primary" + size="h2" + /> + </div> + + <p> + Experience seamless communication and lightning-fast internet across + the entire country. Whether you're streaming, gaming, or working, + our robust network keeps you connected. + </p> + + <div + class="grid grid-cols-1 sm:grid-cols-3 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-8" + > + { + stats.map((stat) => ( + <div + class={cn( + "flex flex-col items-start gap-2", + stat.id === 1 ? "md:ml-4" : "", + )} + > + <span class="text-4xl font-bold text-primary"> + {stat.count} + </span> + <span class="text-lg font-medium">{stat.title}</span> + </div> + )) + } + </div> + </div> + + <Image + src={"/assets/map.png"} + alt="hero image" + class={"w-full h-full object-contain max-w-lg lg:max-w-none"} + width={500} + height={500} + data-aos="fade-up" + data-aos-duration="500" + /> +</MaxWidthWrapper> diff --git a/src/components/Testimonials.astro b/src/components/Testimonials.astro new file mode 100644 index 0000000..c9d0a1b --- /dev/null +++ b/src/components/Testimonials.astro @@ -0,0 +1,84 @@ +--- +import { Star } from "lucide-react"; + +const { + variant = "lime", + testimonials = [ + { + name: "Amber J.", + image: "https://images.unsplash.com/photo-1534528741775-53994a69daeb?q=80&w=3276&auto=format&fit=crop", + stars: 5, + review: "Switching to this service was the best decision for our family. The internet speed is consistently fast, and the TV service offers an amazing selection of channels. Customer service has been exceptional whenever we needed help.", + }, + { + name: "Mike L.", + image: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?q=80&w=3387&auto=format&fit=crop", + stars: 5, + review: "I've been a customer for over 2 years now, and I'm impressed with the reliability of their services. The bundle package offers great value, and the internet speeds are perfect for my work-from-home needs.", + }, + { + name: "Emma M.", + image: "https://images.unsplash.com/photo-1580489944761-15a19d654956?q=80&w=3361&auto=format&fit=crop", + stars: 5, + review: "The installation was quick and professional, and the service has been rock-solid ever since. I particularly love the DVR features and the ability to stream on multiple devices. Definitely worth every penny!", + }, + ], +} = Astro.props; + +// Map variant to color classes for stars +const variantColorMap = { + lime: "text-lime-500", + amber: "text-amber-500", +}; +const starColor = + variantColorMap[variant as keyof typeof variantColorMap] || "text-lime-500"; +--- + +<section class="bg-white"> + <div + class="mx-auto max-w-(--breakpoint-xl) px-4 py-12 sm:px-6 lg:px-8 lg:py-16" + > + <h2 + class="text-center text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl" + > + Read trusted reviews from our customers + </h2> + + <div class="mt-8 grid grid-cols-1 gap-4 md:grid-cols-3 md:gap-8"> + { + testimonials.map((testimonial: any) => ( + <blockquote class="rounded-lg bg-gray-50 p-6 shadow-xs sm:p-8"> + <div class="flex items-center gap-4"> + <img + alt={`${testimonial.name}'s profile picture`} + src={testimonial.image} + class="size-14 rounded-full object-cover" + /> + + <div> + <div + class={`flex justify-center gap-0.5 ${starColor}`} + > + {Array(testimonial.stars) + .fill(null) + .map((_, i) => ( + <Star + key={i} + className="h-5 w-5 fill-current" + /> + ))} + </div> + + <p class="mt-0.5 text-lg font-medium text-gray-900"> + {testimonial.name} + </p> + </div> + </div> + + <p class="mt-4 text-gray-700">{testimonial.review}</p> + </blockquote> + )) + } + </div> + </div> +</section> diff --git a/src/components/WhyChooseUs.astro b/src/components/WhyChooseUs.astro new file mode 100644 index 0000000..da1ad71 --- /dev/null +++ b/src/components/WhyChooseUs.astro @@ -0,0 +1,115 @@ +--- +import MaxWidthWrapper from "./other/max.width.wrapper"; +import Title from "./atoms/title"; +import CheckCircleIcon from "@/components/atoms/check.circle.icon.astro"; +import { Image } from "astro:assets"; +import CallButton from "./molecules/CallButton"; + +const { + variant = "lime", + logo, + title = "Why Choose Net Fundable?", + description = "Experience superior connectivity and entertainment with comprehensive service packages. Whether you're streaming, working, or staying connected with loved ones, we've got you covered.", + benefits = [ + "No Contracts Required", + "30-Day Money-Back Guarantee", + "24/7 Customer Support", + "Free HD & Equipment", + ], + buttonText = "Get Started Today", + page, +} = Astro.props; +--- + +<MaxWidthWrapper + id="why-choose-us" + className="grid h-full w-full grid-cols-1 place-items-center md:grid-cols-2 gap-20 lg:gap-8" +> + <Image + src={"/assets/about-image.png"} + width={300} + height={400} + alt="Provider features" + class={"w-full h-auto object-contain max-w-lg lg:max-w-none"} + data-aos="fade-right" + data-aos-duration="500" + /> + <div + class="flex flex-col gap-8" + data-aos="fade-left" + data-aos-duration="400" + > + <img + src={logo} + alt="Logo" + class={"h-14 w-max object-contain md:h-16 lg:h-20"} + /> + + <div class="flex flex-col gap-2"> + <Title title={title} color="primary" size="h2" /> + </div> + + <p> + {description} + </p> + + <div class="grid grid-cols-1 w-full gap-2 md:gap-4"> + <div + class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-1 lg:grid-cols-2 gap-2" + > + <div + class="flex items-center gap-2" + data-aos="zoom-in" + data-aos-duration="500" + > + <CheckCircleIcon /> + <span class="text-sm md:text-base lg:text-lg font-medium"> + {benefits[0]} + </span> + </div> + <div + class="flex items-center gap-2" + data-aos="zoom-in" + data-aos-duration="600" + > + <CheckCircleIcon /> + <span class="text-sm md:text-base lg:text-lg font-medium"> + {benefits[1]} + </span> + </div> + </div> + + <div + class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-1 lg:grid-cols-2 gap-2" + > + <div + class="flex items-center gap-2" + data-aos="zoom-in" + data-aos-duration="700" + > + <CheckCircleIcon /> + <span class="text-sm md:text-base lg:text-lg font-medium"> + {benefits[2]} + </span> + </div> + <div + class="flex items-center gap-2" + data-aos="zoom-in" + data-aos-duration="800" + > + <CheckCircleIcon /> + <span class="text-sm md:text-base lg:text-lg font-medium"> + {benefits[3]} + </span> + </div> + </div> + </div> + + <CallButton + client:load + variant={variant} + text={buttonText} + page={page} + /> + </div> +</MaxWidthWrapper> diff --git a/src/components/atoms/check.circle.icon.astro b/src/components/atoms/check.circle.icon.astro new file mode 100644 index 0000000..1cb6602 --- /dev/null +++ b/src/components/atoms/check.circle.icon.astro @@ -0,0 +1,12 @@ +--- +import { cn } from "@/lib/utils"; +import { CheckIcon } from "lucide-react"; +--- + +<div + class={cn( + "grid place-items-center bg-lime-50 rounded-full text-lime-600 p-2", + )} +> + <CheckIcon size={16} /> +</div> diff --git a/src/components/atoms/grid.pattern.tsx b/src/components/atoms/grid.pattern.tsx new file mode 100644 index 0000000..6160eaf --- /dev/null +++ b/src/components/atoms/grid.pattern.tsx @@ -0,0 +1,77 @@ +import { useId } from "react"; + +import { cn } from "@/lib/utils"; + +interface GridPatternProps { + width?: any; + height?: any; + x?: any; + y?: any; + squares?: Array<[x: number, y: number]>; + strokeDasharray?: any; + className?: string; + [key: string]: any; +} + +export function GridPattern({ + width = 40, + height = 40, + x = -1, + y = -1, + strokeDasharray = 0, + squares, + className, + ...props +}: GridPatternProps) { + const id = useId(); + + return ( + <svg + aria-hidden="true" + className={cn( + "pointer-events-none absolute inset-0 h-full w-full fill-gray-400/30 stroke-gray-400/30", + className, + )} + {...props} + > + <defs> + <pattern + id={id} + width={width} + height={height} + patternUnits="userSpaceOnUse" + x={x} + y={y} + > + <path + d={`M.5 ${height}V.5H${width}`} + fill="none" + strokeDasharray={strokeDasharray} + /> + </pattern> + </defs> + <rect + width="100%" + height="100%" + strokeWidth={0} + fill={`url(#${id})`} + /> + {squares && ( + <svg x={x} y={y} className="overflow-visible"> + {squares.map(([x, y]) => ( + <rect + strokeWidth="0" + key={`${x}-${y}`} + width={width - 1} + height={height - 1} + x={x * width + 1} + y={y * height + 1} + /> + ))} + </svg> + )} + </svg> + ); +} + +export default GridPattern; diff --git a/src/components/atoms/logo.tsx b/src/components/atoms/logo.tsx new file mode 100644 index 0000000..9bc439b --- /dev/null +++ b/src/components/atoms/logo.tsx @@ -0,0 +1,14 @@ +function Logo() { + return ( + <img + src="/logo.svg" + alt="Logo" + width={0} + height={0} + sizes="100%" + className="h-10 w-auto object-contain sm:h-12 md:h-14 lg:h-16" + /> + ); +} + +export default Logo; diff --git a/src/components/atoms/section.heading.tsx b/src/components/atoms/section.heading.tsx new file mode 100644 index 0000000..fa67f41 --- /dev/null +++ b/src/components/atoms/section.heading.tsx @@ -0,0 +1,35 @@ +import { cn } from "@/lib/utils"; + +function SectionHeading({ + title, + subtitle, + invertColors, +}: { + title: string; + subtitle: string; + invertColors?: boolean; +}) { + return ( + <div className="relative grid w-full place-items-center gap-2 text-center"> + <h2 + className={cn( + "z-10 font-serif text-lg leading-none font-semibold uppercase md:text-xl lg:text-2xl", + invertColors ? "text-lime-100" : "text-lime-700", + )} + > + {subtitle} + </h2> + + <div + className={cn( + "text-3xl font-bold md:text-4xl lg:text-5xl", + invertColors ? "text-lime-50" : "text-lime-900", + )} + > + {title} + </div> + </div> + ); +} + +export default SectionHeading; diff --git a/src/components/atoms/title.tsx b/src/components/atoms/title.tsx new file mode 100644 index 0000000..0439b85 --- /dev/null +++ b/src/components/atoms/title.tsx @@ -0,0 +1,66 @@ +import clsx from "clsx"; +import React from "react"; + +const weights = { + bold: "font-bold", + semibold: "font-semibold", + medium: "font-medium", + normal: "font-normal", +} as const; + +const colors = { + washedWhite: "text-white", + white: "text-lime-50", + black: "text-neutral-900", + primary: "text-lime-900", + primary300: "text-lime-300", + primary900: "text-lime-900", + secondary: "text-sgreen", + muted: "text-neutral-400", + gradientPrimary: + "bg-linear-to-br from-lime-500 to-lime-700 inline-block text-transparent bg-clip-text", + destructive: "text-rose-700", +}; + +function Title({ + title, + size, + weight, + capitalize, + uppercase, + color, + id, +}: { + title: string; + capitalize?: boolean; + uppercase?: boolean; + size?: "h1" | "h2" | "h3" | "h4" | "h5"; + weight?: keyof typeof weights; + color?: keyof typeof colors; + id?: string; +}) { + return React.createElement( + size ?? "h1", + { + id, + className: clsx( + "font-serif leading-tight", + { + h1: "text-4xl md:text-5xl xl:text-6xl", + h2: "text-3xl lg:text-4xl", + h3: "text-xl md:text-2xl lg:text-3xl", + h4: "text-lg md:text-xl lg:text-2xl", + h5: "text-base lg:text-lg", + }[size ?? "h1"], + colors[color ?? "secondary"], + weights[weight ?? "semibold"], + capitalize && "capitalize", + uppercase && "uppercase", + "bg-clip-padding pb-1", + ), + }, + title, + ); +} + +export default Title; diff --git a/src/components/molecules/CallButton.tsx b/src/components/molecules/CallButton.tsx new file mode 100644 index 0000000..8c89043 --- /dev/null +++ b/src/components/molecules/CallButton.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { getPhoneNumber, getContactLink } from "@/lib/constants"; +import { cn } from "@/lib/utils"; + +interface CallButtonProps { + variant?: "lime" | "amber"; + fullWidth?: boolean; + text?: string; + showIcon?: boolean; + rounded?: boolean; + page?: string; +} + +export default function CallButton({ + variant = "lime", + fullWidth, + text, + rounded, + page, +}: CallButtonProps) { + const handleClick = () => { + if ( + typeof window !== "undefined" && + typeof window.gtag_report_conversion === "function" + ) { + window.gtag_report_conversion(); + console.log("called conversion tracking"); + } else { + console.error("gtag_report_conversion function not found"); + } + }; + + const phoneNumber = getPhoneNumber(page); + const contactLink = getContactLink(page); + + return ( + <a href={contactLink}> + <Button + size="lg" + variant={variant} + onClick={handleClick} + className={cn(fullWidth ? "w-full" : "")} + rounded={rounded ? "full" : "default"} + > + <p>{text ? text : `Call ${phoneNumber}`}</p> + </Button> + </a> + ); +} diff --git a/src/components/molecules/certification-details.tsx b/src/components/molecules/certification-details.tsx new file mode 100644 index 0000000..0a9dd1f --- /dev/null +++ b/src/components/molecules/certification-details.tsx @@ -0,0 +1,80 @@ +import Title from "@/components/atoms/title"; +import { Button } from "@/components/ui/button"; +import React from "react"; +import { Dialog, DialogContent, DialogHeader } from "@/components/ui/dialog"; + +function CertificationDetails(props: { + yes: { title: string; skills: string[] }[]; +}) { + const [open, setOpen] = React.useState(false); + + return ( + <> + <Dialog + open={open} + onOpenChange={(o) => { + setOpen(o); + }} + > + <DialogContent> + <DialogHeader> + <Title + title="Specialization Details" + size="h2" + capitalize + /> + </DialogHeader> + + <div className="flex max-h-[70vh] w-full flex-wrap items-center justify-center gap-0 overflow-y-auto py-8"> + {props.yes.map((item, index) => ( + <div key={index} className="flex w-full gap-2"> + <div className="flex w-8 flex-col items-center"> + <div className="h-2 w-0.5 bg-lime-200"> + {" "} + </div> + <div className="h-3 w-3 rounded-full bg-lime-200"></div> + <div className="h-full w-0.5 bg-lime-200"> + {" "} + </div> + </div> + <div className="flex w-full flex-col gap-2 pb-4"> + <Title + title={item.title} + size="h4" + weight="semibold" + /> + <div className="flex flex-wrap gap-2"> + {item.skills.map((skill, index) => ( + <small + key={index} + className="h-max rounded-md border p-0.5 px-1" + > + {skill} + </small> + ))} + </div> + </div> + </div> + ))} + </div> + + <Button variant={"muted"}> + <a + className="h-full w-full" + target="_blank" + href="https://www.coursera.org/account/accomplishments/specialization/certificate/TFKYJD4BT977" + > + View Certificate + </a> + </Button> + </DialogContent> + </Dialog> + + <Button onClick={() => setOpen(true)} variant={"muted"}> + Show Details + </Button> + </> + ); +} + +export default CertificationDetails; diff --git a/src/components/molecules/footer.astro b/src/components/molecules/footer.astro new file mode 100644 index 0000000..a53e5fb --- /dev/null +++ b/src/components/molecules/footer.astro @@ -0,0 +1,78 @@ +--- +import Logo from "../atoms/logo"; +import MaxWidthWrapper from "../other/max.width.wrapper"; +import { + ADDRESS, + getContactLink, + getPhoneNumber, + navLinks, +} from "@/lib/constants"; +import Title from "../atoms/title"; +import { cn } from "@/lib/utils"; + +const { page, variant = "lime" } = Astro.props; + +const bgGradient = + variant === "lime" + ? "from-lime-800 to-lime-950" + : "from-blue-900 to-blue-950"; + +const phoneNumber = getPhoneNumber(page); +const contactLink = getContactLink(page); +--- + +<div class={cn("bg-linear-to-b mt-24", bgGradient)}> + <MaxWidthWrapper + id="footer" + className="grid place-items-center w-full py-24" + > + <footer aria-labelledby="footer-heading" class="w-full"> + <h2 id="footer-heading" class="sr-only">Footer</h2> + <div class="mx-auto max-w-7xl px-2"> + <div class="flex flex-col justify-between lg:flex-row gap-12"> + <div class="flex flex-col gap-8"> + <a href="/"> + <Logo /> + </a> + <p class="text-md leading-6 text-lime-50"> + Address: + <span>{ADDRESS}</span> + </p> + <p class="text-md leading-6 text-lime-50"> + Phone: + <a + href={contactLink} + class="text-lime-50 hover:text-lime-100" + > + {phoneNumber} + </a> + </p> + </div> + <div class="md:mt-0 flex flex-col md:items-end gap-6"> + <Title size="h3" title="SiteMap" color="white" /> + + <div class="flex flex-col gap-4 md:items-end"> + { + navLinks.map((item) => ( + <a + href={item.href} + class="leading-6 text-gray-50 hover:text-gray-100" + > + {item.label} + </a> + )) + } + </div> + </div> + </div> + <div + class="mt-16 border-t border-lime-50/10 pt-8 sm:mt-20 lg:mt-24" + > + <p class="text-xs leading-5 text-lime-50"> + © 2025 NetFundable. All rights reserved. + </p> + </div> + </div> + </footer> + </MaxWidthWrapper> +</div> diff --git a/src/components/molecules/navbar.tsx b/src/components/molecules/navbar.tsx new file mode 100644 index 0000000..a46b122 --- /dev/null +++ b/src/components/molecules/navbar.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { CONTACT_LINK, navLinks, TRANSITION_COLORS } from "@/lib/constants"; +import Logo from "../atoms/logo"; +import React from "react"; +import * as Sheet from "@/components/ui/sheet"; +import { Button } from "../ui/button"; +import { cn } from "@/lib/utils"; +import { Menu } from "lucide-react"; +import MaxWidthWrapper from "../other/max.width.wrapper"; +import CallButton from "./CallButton"; + +function Navbar({ + page, + variant = "lime", +}: { + page: string; + variant?: "lime" | "amber"; +}) { + const [open, setOpen] = React.useState(false); + + const [scrolledPos, setScrolledPos] = React.useState(0); + + React.useEffect(() => { + window.addEventListener("scroll", () => { + setScrolledPos(window.scrollY); + }); + }, []); + + const bgColor = variant === "lime" ? "bg-lime-900/90" : "bg-blue-950/90"; + + return ( + <> + <nav + className={cn( + "fixed z-999 grid w-screen place-items-center p-2 md:p-4 lg:p-6", + )} + > + <MaxWidthWrapper + className={cn( + "flex items-center justify-between rounded-lg py-4", + scrolledPos > 50 + ? `${bgColor} backdrop-blur-md` + : "bg-transparent", + TRANSITION_COLORS, + )} + > + <a href="/" className={cn("cursor-pointer")}> + <Logo /> + </a> + + <div className="hidden items-center gap-8 lg:flex xl:gap-12"> + {navLinks.map((link, i) => ( + <a + key={i} + href={link.href} + className="hidden text-lg text-lime-50 md:block" + > + {link.label} + </a> + ))} + + <CallButton variant={variant} page={page} /> + </div> + + <Button + className="lg:hidden" + onClick={() => setOpen(true)} + variant={"ghost"} + size={"icon"} + > + <Menu className="h-6 w-6 text-lime-50 hover:text-lime-600" /> + </Button> + </MaxWidthWrapper> + </nav> + + <Sheet.Sheet open={open} onOpenChange={(o) => setOpen(o)}> + <Sheet.SheetContent + className="z-1000 flex flex-col lg:hidden" + side="bottom" + > + <ul className="flex flex-col gap-8 pt-8"> + {navLinks.map((nl, i) => ( + <li + key={i} + onClick={() => { + setOpen(false); + }} + > + <a href={nl.href}> + <Button + variant={"ghost"} + className="w-full items-start justify-start text-start" + onClick={() => { + setOpen(false); + }} + > + {nl.label} + </Button> + </a> + </li> + ))} + + <a href={CONTACT_LINK}> + <Button + variant={variant} + className="w-full" + onClick={() => { + // @ts-ignore + gtag_report_conversion(); + setOpen(false); + }} + > + Contact us + </Button> + </a> + </ul> + </Sheet.SheetContent> + </Sheet.Sheet> + </> + ); +} + +export default Navbar; diff --git a/src/components/molecules/pricing.card.astro b/src/components/molecules/pricing.card.astro new file mode 100644 index 0000000..dc930ff --- /dev/null +++ b/src/components/molecules/pricing.card.astro @@ -0,0 +1,102 @@ +--- +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import Title from "../atoms/title"; +import { cn } from "@/lib/utils"; +import CheckCircleIcon from "@/components/atoms/check.circle.icon.astro"; +import CallButton from "./CallButton"; + +const { title, price, description, features, isMostPopular, aos, color, page } = + Astro.props; + +const colors = { + default: { + border: "border-lime-500", + textDarkest: "text-lime-900", + textDark: "text-lime-700", + bg: "bg-lime-50", + }, + lime: { + border: "border-lime-500", + textDarkest: "text-lime-900", + textDark: "text-lime-700", + bg: "bg-lime-50", + }, + blue: { + border: "border-blue-900", + textDarkest: "text-blue-900", + textDark: "text-blue-700", + bg: "bg-blue-50", + }, + amber: { + border: "border-amber-500", + text: "text-amber-900", + }, +}; + +function getColoredStyle(type: keyof (typeof colors)["default"]) { + // @ts-ignore + return colors[color ?? "default"][type]; +} +--- + +<Card + className={cn( + "w-full pt-8 px-4", + isMostPopular + ? "border-2 " + getColoredStyle("border") + : "border border-gray-200", + )} + {...aos} +> + <CardHeader className="relative text-start w-full space-y-4"> + <Title title={title} size="h3" /> + <p class="text-base md:text-lg font-medium">{description}</p> + <div class="flex items-center gap-1"> + <Title title={`$${price}`} size="h2" /> + <span>/ month</span> + </div> + </CardHeader> + <CardContent className="flex flex-col gap-6 w-full"> + { + isMostPopular && ( + <div class="hidden absolute top-5 sm:grid place-items-center w-full right-0 left-0"> + <span + class={cn( + "p-1 px-3 font-medium rounded-full whitespace-nowrap md:text-sm", + getColoredStyle("bg"), + getColoredStyle("textDark"), + )} + > + Most popular + </span> + </div> + ) + } + <div class="w-full space-y-2"> + { + features.map((feature: string[]) => ( + <div class="flex items-center gap-2 text-start"> + <CheckCircleIcon invert /> + <span class="text-sm sm:text-base lg:text-sm xl:text-base"> + {feature} + </span> + </div> + )) + } + </div> + + <CallButton + client:load + fullWidth + text="Get Started" + variant={color + ? isMostPopular + ? color + : "ghost" + : isMostPopular + ? "brand" + : "muted"} + page={page} + /> + </CardContent> +</Card> diff --git a/src/components/molecules/skill.card.tsx b/src/components/molecules/skill.card.tsx new file mode 100644 index 0000000..8b6a4e2 --- /dev/null +++ b/src/components/molecules/skill.card.tsx @@ -0,0 +1,67 @@ +import { Dialog, DialogContent, DialogHeader } from "@/components/ui/dialog"; +import Title from "@/components/atoms/title"; +import React from "react"; + +function SkillCard(props: { title: string; skills: any[] }) { + if (!props) return null; + const { title, skills } = props; + const [open, setOpen] = React.useState(false); + + return ( + <> + <Dialog + open={open} + onOpenChange={(o) => { + setOpen(o); + }} + > + <DialogContent> + <DialogHeader> + <Title title={title} size="h2" capitalize /> + </DialogHeader> + + <div className="flex w-full flex-wrap items-center justify-center gap-4 py-8"> + {skills.map((skill: any, i) => ( + <div + key={i} + className="border-border flex w-max items-center gap-4 rounded-md border p-1.5 px-2 sm:p-2 sm:px-2.5" + > + <img + src={skill.icon} + alt="0" + className="aspect-square h-8 w-auto" + /> + <span className="text-lime">{skill.label}</span> + </div> + ))} + </div> + </DialogContent> + </Dialog> + <button + onClick={() => { + setOpen(true); + }} + className="jusitify-center border-border flex w-[28rem] flex-col items-center gap-6 rounded-xl border p-8 md:w-[36rem]" + > + <Title title={title} size="h3" capitalize weight="semibold" /> + <div className="flex w-full flex-wrap items-center justify-center gap-4"> + {skills.map((skill: any, i) => ( + <div + key={i} + className="border-border flex w-max items-center gap-4 rounded-md border p-1.5 px-2 sm:p-2 sm:px-2.5" + > + <img + src={skill.icon} + alt="0" + className="aspect-square h-8 w-auto" + /> + <span className="text-lime">{skill.label}</span> + </div> + ))} + </div> + </button> + </> + ); +} + +export default SkillCard; diff --git a/src/components/other/icons.tsx b/src/components/other/icons.tsx new file mode 100644 index 0000000..ddf1452 --- /dev/null +++ b/src/components/other/icons.tsx @@ -0,0 +1,77 @@ +import type { SVGProps } from "react"; + +export function HugoIconNanoTechnology(props: SVGProps<SVGSVGElement>) { + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="1em" + height="1em" + viewBox="0 0 24 24" + {...props} + > + <g + fill="none" + stroke="currentColor" + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={1.5} + color="currentColor" + > + <path d="m5 16l5-3m4-2l5-3m-7-3v5m0 4v5M5 8l5 3m4 2l5 3m1.5-7v5.5m-7 6l5.5-3m-14.5 0l6 3m-7-5.5V9m1-2.5l6-3m9 3l-6-3"></path> + <circle cx={12} cy={3.5} r={1.5}></circle> + <circle cx={12} cy={20.5} r={1.5}></circle> + <circle cx={3.5} cy={7.5} r={1.5}></circle> + <circle cx={20.5} cy={7.5} r={1.5}></circle> + <circle cx={20.5} cy={16.5} r={1.5}></circle> + <circle cx={3.5} cy={16.5} r={1.5}></circle> + <path d="m12 9.75l2 1.125v2.25l-2 1.125l-2-1.125v-2.25z"></path> + </g> + </svg> + ); +} + +export function HugeIconTwitterX(props: SVGProps<SVGSVGElement>) { + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="1em" + height="1em" + viewBox="0 0 24 24" + {...props} + > + <path + fill="none" + stroke="currentColor" + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={1.5} + d="m3 21l7.548-7.548M21 3l-7.548 7.548m0 0L8 3H3l7.548 10.452m2.904-2.904L21 21h-5l-5.452-7.548" + color="currentColor" + ></path> + </svg> + ); +} + +export function GithubIcon(props: SVGProps<SVGSVGElement>) { + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="1em" + height="1em" + viewBox="0 0 24 24" + {...props} + > + <g + fill="none" + stroke="currentColor" + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={1.5} + color="currentColor" + > + <path d="M10 20.568c-3.429 1.157-6.286 0-8-3.568"></path> + <path d="M10 22v-3.242c0-.598.184-1.118.48-1.588c.204-.322.064-.78-.303-.88C7.134 15.452 5 14.107 5 9.645c0-1.16.38-2.25 1.048-3.2c.166-.236.25-.354.27-.46c.02-.108-.015-.247-.085-.527c-.283-1.136-.264-2.343.16-3.43c0 0 .877-.287 2.874.96c.456.285.684.428.885.46s.469-.035 1.005-.169A9.5 9.5 0 0 1 13.5 3a9.6 9.6 0 0 1 2.343.28c.536.134.805.2 1.006.169c.2-.032.428-.175.884-.46c1.997-1.247 2.874-.96 2.874-.96c.424 1.087.443 2.294.16 3.43c-.07.28-.104.42-.084.526s.103.225.269.461c.668.95 1.048 2.04 1.048 3.2c0 4.462-2.134 5.807-5.177 6.643c-.367.101-.507.559-.303.88c.296.47.48.99.48 1.589V22"></path> + </g> + </svg> + ); +} diff --git a/src/components/other/marquee.tsx b/src/components/other/marquee.tsx new file mode 100644 index 0000000..2ecd592 --- /dev/null +++ b/src/components/other/marquee.tsx @@ -0,0 +1,55 @@ +import { cn } from "@/lib/utils"; + +interface MarqueeProps { + className?: string; + reverse?: boolean; + pauseOnHover?: boolean; + children?: React.ReactNode; + vertical?: boolean; + repeat?: number; + [key: string]: any; +} + +export default function Marquee({ + className, + reverse, + pauseOnHover = false, + children, + vertical = false, + repeat = 4, + ...props +}: MarqueeProps) { + return ( + <div + {...props} + className={cn( + "group flex overflow-hidden p-2 [--duration:40s] [--gap:1rem] [gap:var(--gap)]", + { + "flex-row": !vertical, + "flex-col": vertical, + }, + className, + )} + > + {Array(repeat) + .fill(0) + .map((_, i) => ( + <div + key={i} + className={cn( + "flex shrink-0 justify-around [gap:var(--gap)]", + { + "animate-marquee flex-row": !vertical, + "animate-marquee-vertical flex-col": vertical, + "group-hover:[animation-play-state:paused]": + pauseOnHover, + "[animation-direction:reverse]": reverse, + }, + )} + > + {children} + </div> + ))} + </div> + ); +} diff --git a/src/components/other/max.width.wrapper.tsx b/src/components/other/max.width.wrapper.tsx new file mode 100644 index 0000000..1a55221 --- /dev/null +++ b/src/components/other/max.width.wrapper.tsx @@ -0,0 +1,26 @@ +import { cn } from "@/lib/utils"; +import type { ReactNode } from "react"; + +const MaxWidthWrapper = ({ + className, + id, + children, +}: { + className?: string; + id?: string; + children: ReactNode; +}) => { + return ( + <section + className={cn( + "mx-auto h-full w-full max-w-(--breakpoint-2xl) px-8 md:px-12 lg:px-16", + className, + )} + id={id} + > + {children} + </section> + ); +}; + +export default MaxWidthWrapper; diff --git a/src/components/other/particles.tsx b/src/components/other/particles.tsx new file mode 100644 index 0000000..cf9c068 --- /dev/null +++ b/src/components/other/particles.tsx @@ -0,0 +1,280 @@ +import React, { useEffect, useRef, useState } from "react"; + +interface MousePosition { + x: number; + y: number; +} + +function MousePosition(): MousePosition { + const [mousePosition, setMousePosition] = useState<MousePosition>({ + x: 0, + y: 0, + }); + + useEffect(() => { + const handleMouseMove = (event: MouseEvent) => { + setMousePosition({ x: event.clientX, y: event.clientY }); + }; + + window.addEventListener("mousemove", handleMouseMove); + + return () => { + window.removeEventListener("mousemove", handleMouseMove); + }; + }, []); + + return mousePosition; +} + +interface ParticlesProps { + className?: string; + quantity?: number; + staticity?: number; + ease?: number; + size?: number; + refresh?: boolean; + color?: string; + vx?: number; + vy?: number; +} +function hexToRgb(hex: string): number[] { + hex = hex.replace("#", ""); + const hexInt = parseInt(hex, 16); + const red = (hexInt >> 16) & 255; + const green = (hexInt >> 8) & 255; + const blue = hexInt & 255; + return [red, green, blue]; +} + +const Particles: React.FC<ParticlesProps> = ({ + className = "", + quantity = 100, + staticity = 50, + ease = 50, + size = 0.4, + refresh = false, + color = "#ffffff", + vx = 0, + vy = 0, +}) => { + const canvasRef = useRef<HTMLCanvasElement>(null); + const canvasContainerRef = useRef<HTMLDivElement>(null); + const context = useRef<CanvasRenderingContext2D | null>(null); + const circles = useRef<any[]>([]); + const mousePosition = MousePosition(); + const mouse = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + const canvasSize = useRef<{ w: number; h: number }>({ w: 0, h: 0 }); + const dpr = typeof window !== "undefined" ? window.devicePixelRatio : 1; + + useEffect(() => { + if (canvasRef.current) { + context.current = canvasRef.current.getContext("2d"); + } + initCanvas(); + animate(); + window.addEventListener("resize", initCanvas); + + return () => { + window.removeEventListener("resize", initCanvas); + }; + }, [color]); + + useEffect(() => { + onMouseMove(); + }, [mousePosition.x, mousePosition.y]); + + useEffect(() => { + initCanvas(); + }, [refresh]); + + const initCanvas = () => { + resizeCanvas(); + drawParticles(); + }; + + const onMouseMove = () => { + if (canvasRef.current) { + const rect = canvasRef.current.getBoundingClientRect(); + const { w, h } = canvasSize.current; + const x = mousePosition.x - rect.left - w / 2; + const y = mousePosition.y - rect.top - h / 2; + const inside = x < w / 2 && x > -w / 2 && y < h / 2 && y > -h / 2; + if (inside) { + mouse.current.x = x; + mouse.current.y = y; + } + } + }; + + type Circle = { + x: number; + y: number; + translateX: number; + translateY: number; + size: number; + alpha: number; + targetAlpha: number; + dx: number; + dy: number; + magnetism: number; + }; + + const resizeCanvas = () => { + if ( + canvasContainerRef.current && + canvasRef.current && + context.current + ) { + circles.current.length = 0; + canvasSize.current.w = canvasContainerRef.current.offsetWidth; + canvasSize.current.h = canvasContainerRef.current.offsetHeight; + canvasRef.current.width = canvasSize.current.w * dpr; + canvasRef.current.height = canvasSize.current.h * dpr; + canvasRef.current.style.width = `${canvasSize.current.w}px`; + canvasRef.current.style.height = `${canvasSize.current.h}px`; + context.current.scale(dpr, dpr); + } + }; + + const circleParams = (): Circle => { + const x = Math.floor(Math.random() * canvasSize.current.w); + const y = Math.floor(Math.random() * canvasSize.current.h); + const translateX = 0; + const translateY = 0; + const pSize = Math.floor(Math.random() * 2) + size; + const alpha = 0; + const targetAlpha = parseFloat((Math.random() * 0.6 + 0.1).toFixed(1)); + const dx = (Math.random() - 0.5) * 0.1; + const dy = (Math.random() - 0.5) * 0.1; + const magnetism = 0.1 + Math.random() * 4; + return { + x, + y, + translateX, + translateY, + size: pSize, + alpha, + targetAlpha, + dx, + dy, + magnetism, + }; + }; + + const rgb = hexToRgb(color); + + const drawCircle = (circle: Circle, update = false) => { + if (context.current) { + const { x, y, translateX, translateY, size, alpha } = circle; + context.current.translate(translateX, translateY); + context.current.beginPath(); + context.current.arc(x, y, size, 0, 2 * Math.PI); + context.current.fillStyle = `rgba(${rgb.join(", ")}, ${alpha})`; + context.current.fill(); + context.current.setTransform(dpr, 0, 0, dpr, 0, 0); + + if (!update) { + circles.current.push(circle); + } + } + }; + + const clearContext = () => { + if (context.current) { + context.current.clearRect( + 0, + 0, + canvasSize.current.w, + canvasSize.current.h, + ); + } + }; + + const drawParticles = () => { + clearContext(); + const particleCount = quantity; + for (let i = 0; i < particleCount; i++) { + const circle = circleParams(); + drawCircle(circle); + } + }; + + const remapValue = ( + value: number, + start1: number, + end1: number, + start2: number, + end2: number, + ): number => { + const remapped = + ((value - start1) * (end2 - start2)) / (end1 - start1) + start2; + return remapped > 0 ? remapped : 0; + }; + + const animate = () => { + clearContext(); + circles.current.forEach((circle: Circle, i: number) => { + // Handle the alpha value + const edge = [ + circle.x + circle.translateX - circle.size, // distance from left edge + canvasSize.current.w - + circle.x - + circle.translateX - + circle.size, // distance from right edge + circle.y + circle.translateY - circle.size, // distance from top edge + canvasSize.current.h - + circle.y - + circle.translateY - + circle.size, // distance from bottom edge + ]; + const closestEdge = edge.reduce((a, b) => Math.min(a, b)); + const remapClosestEdge = parseFloat( + remapValue(closestEdge, 0, 20, 0, 1).toFixed(2), + ); + if (remapClosestEdge > 1) { + circle.alpha += 0.02; + if (circle.alpha > circle.targetAlpha) { + circle.alpha = circle.targetAlpha; + } + } else { + circle.alpha = circle.targetAlpha * remapClosestEdge; + } + circle.x += circle.dx + vx; + circle.y += circle.dy + vy; + circle.translateX += + (mouse.current.x / (staticity / circle.magnetism) - + circle.translateX) / + ease; + circle.translateY += + (mouse.current.y / (staticity / circle.magnetism) - + circle.translateY) / + ease; + + drawCircle(circle, true); + + // circle gets out of the canvas + if ( + circle.x < -circle.size || + circle.x > canvasSize.current.w + circle.size || + circle.y < -circle.size || + circle.y > canvasSize.current.h + circle.size + ) { + // remove the circle from the array + circles.current.splice(i, 1); + // create a new circle + const newCircle = circleParams(); + drawCircle(newCircle); + // update the circle position + } + }); + window.requestAnimationFrame(animate); + }; + + return ( + <div className={className} ref={canvasContainerRef} aria-hidden="true"> + <canvas ref={canvasRef} className="h-full w-full" /> + </div> + ); +}; + +export default Particles; diff --git a/src/components/pages/optimum/Pricing.astro b/src/components/pages/optimum/Pricing.astro new file mode 100644 index 0000000..14b7eb9 --- /dev/null +++ b/src/components/pages/optimum/Pricing.astro @@ -0,0 +1,72 @@ +--- +import PricingCard from "@/components/molecules/pricing.card.astro"; +import Title from "@/components/atoms/title"; +import { CONTACT_LINK } from "@/lib/constants"; + +const packages1 = [ + { + title: "Fiber Connect", + price: "44.99", + description: + "$34.99 for first year with autopay enrollment and paperless billing.", + features: [ + "Up to 300 Mbps symmetrical speeds", + "Perfect for multi-device households", + "Smart WiFi network management", + "Zero data limitations", + "Free security essentials package", + ], + aos: { + "data-aos": "fade-up", + "data-aos-duration": "400", + }, + }, + { + title: "Fiber Select", + price: "64.99", + description: + "$45.99 for first year with autopay enrollment and paperless billing.", + features: [ + "Up to 500 Mbps symmetrical speeds", + "$75 service credit (terms apply)", + "Ideal for streaming, gaming & remote work", + "Advanced whole-home WiFi coverage", + "Premium network security features", + ], + aos: { + "data-aos": "fade-up", + "data-aos-duration": "600", + }, + isMostPopular: true, + }, + { + title: "Fiber Premier", + price: "84.99", + description: + "$69.99 for first year with autopay enrollment and paperless billing.", + features: [ + "Up to 1 Gbps symmetrical speeds", + "$225 service credit (terms apply)", + "Ultimate performance for demanding households", + "WiFi 6 equipment with extended coverage", + "Premium tech support with priority scheduling", + ], + aos: { + "data-aos": "fade-up", + "data-aos-duration": "800", + }, + }, +]; +--- + +<div class="grid place-items-center w-full gap-8 text-center"> + <Title title="Power your home with the best in Fiber Internet." size="h1" /> + + <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-8"> + { + packages1.map((item) => ( + <PricingCard {...item} color="amber" ctaLink={CONTACT_LINK} /> + )) + } + </div> +</div> diff --git a/src/components/pages/spectrum/Pricing.astro b/src/components/pages/spectrum/Pricing.astro new file mode 100644 index 0000000..5ef8223 --- /dev/null +++ b/src/components/pages/spectrum/Pricing.astro @@ -0,0 +1,75 @@ +--- +import PricingCard from "@/components/molecules/pricing.card.astro"; +import Title from "@/components/atoms/title"; +import { CONTACT_LINK } from "@/lib/constants"; + +const packages1 = [ + { + title: "Triple Play Essential", + price: "89.97", + description: + "Perfect entry-level bundle featuring all your entertainment essentials.", + features: [ + "125+ Channels with local programming", + "Internet speeds up to 300 Mbps", + "Unlimited nationwide calling with 28 premium features", + "Free Spectrum TV app access", + "HD programming included at no extra cost", + ], + aos: { + "data-aos": "fade-up", + "data-aos-duration": "400", + }, + }, + { + title: "Triple Play Select Plus", + price: "119.97", + description: + "Enhanced entertainment package with upgraded speeds and programming.", + features: [ + "175+ Channels including popular entertainment networks", + "Internet speeds up to 500 Mbps", + "Unlimited calling to U.S., Canada, and Mexico", + "Cloud DVR service included", + "Advanced WiFi network management", + ], + aos: { + "data-aos": "fade-up", + "data-aos-duration": "600", + }, + isMostPopular: true, + }, + { + title: "Triple Play Premium", + price: "139.97", + description: + "Ultimate connectivity suite with premium entertainment and maximum speed.", + features: [ + "200+ Channels with premium movie networks", + "Internet speeds up to 1 Gbps (where available)", + "Unlimited international calling to 70+ destinations", + "Enhanced security suite with identity protection", + "Priority customer support and installation", + ], + aos: { + "data-aos": "fade-up", + "data-aos-duration": "800", + }, + }, +]; +--- + +<div class="grid place-items-center w-full gap-8 text-center"> + <Title + title="Bundle and save with an unbeatable offer on Spectrum services! " + size="h1" + /> + + <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-8"> + { + packages1.map((item) => ( + <PricingCard {...item} color="lime" ctaLink={CONTACT_LINK} /> + )) + } + </div> +</div> diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..25e7b47 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef<typeof AlertDialogPrimitive.Overlay>, + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay> +>(({ className, ...props }, ref) => ( + <AlertDialogPrimitive.Overlay + className={cn( + "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + className + )} + {...props} + ref={ref} + /> +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef<typeof AlertDialogPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> +>(({ className, ...props }, ref) => ( + <AlertDialogPortal> + <AlertDialogOverlay /> + <AlertDialogPrimitive.Content + ref={ref} + className={cn( + "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", + className + )} + {...props} + /> + </AlertDialogPortal> +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn( + "flex flex-col space-y-2 text-center sm:text-left", + className + )} + {...props} + /> +) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn( + "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", + className + )} + {...props} + /> +) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef<typeof AlertDialogPrimitive.Title>, + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title> +>(({ className, ...props }, ref) => ( + <AlertDialogPrimitive.Title + ref={ref} + className={cn("text-lg font-semibold", className)} + {...props} + /> +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef<typeof AlertDialogPrimitive.Description>, + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description> +>(({ className, ...props }, ref) => ( + <AlertDialogPrimitive.Description + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef<typeof AlertDialogPrimitive.Action>, + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action> +>(({ className, ...props }, ref) => ( + <AlertDialogPrimitive.Action + ref={ref} + className={cn(buttonVariants(), className)} + {...props} + /> +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef<typeof AlertDialogPrimitive.Cancel>, + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel> +>(({ className, ...props }, ref) => ( + <AlertDialogPrimitive.Cancel + ref={ref} + className={cn( + buttonVariants({ variant: "outline" }), + "mt-2 sm:mt-0", + className + )} + {...props} + /> +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/src/components/ui/breadcrumb.tsx b/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..71a5c32 --- /dev/null +++ b/src/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />) +Breadcrumb.displayName = "Breadcrumb" + +const BreadcrumbList = React.forwardRef< + HTMLOListElement, + React.ComponentPropsWithoutRef<"ol"> +>(({ className, ...props }, ref) => ( + <ol + ref={ref} + className={cn( + "flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5", + className + )} + {...props} + /> +)) +BreadcrumbList.displayName = "BreadcrumbList" + +const BreadcrumbItem = React.forwardRef< + HTMLLIElement, + React.ComponentPropsWithoutRef<"li"> +>(({ className, ...props }, ref) => ( + <li + ref={ref} + className={cn("inline-flex items-center gap-1.5", className)} + {...props} + /> +)) +BreadcrumbItem.displayName = "BreadcrumbItem" + +const BreadcrumbLink = React.forwardRef< + HTMLAnchorElement, + React.ComponentPropsWithoutRef<"a"> & { + asChild?: boolean + } +>(({ asChild, className, ...props }, ref) => { + const Comp = asChild ? Slot : "a" + + return ( + <Comp + ref={ref} + className={cn("transition-colors hover:text-foreground", className)} + {...props} + /> + ) +}) +BreadcrumbLink.displayName = "BreadcrumbLink" + +const BreadcrumbPage = React.forwardRef< + HTMLSpanElement, + React.ComponentPropsWithoutRef<"span"> +>(({ className, ...props }, ref) => ( + <span + ref={ref} + role="link" + aria-disabled="true" + aria-current="page" + className={cn("font-normal text-foreground", className)} + {...props} + /> +)) +BreadcrumbPage.displayName = "BreadcrumbPage" + +const BreadcrumbSeparator = ({ + children, + className, + ...props +}: React.ComponentProps<"li">) => ( + <li + role="presentation" + aria-hidden="true" + className={cn("[&>svg]:size-3.5", className)} + {...props} + > + {children ?? <ChevronRight />} + </li> +) +BreadcrumbSeparator.displayName = "BreadcrumbSeparator" + +const BreadcrumbEllipsis = ({ + className, + ...props +}: React.ComponentProps<"span">) => ( + <span + role="presentation" + aria-hidden="true" + className={cn("flex h-9 w-9 items-center justify-center", className)} + {...props} + > + <MoreHorizontal className="h-4 w-4" /> + <span className="sr-only">More</span> + </span> +) +BreadcrumbEllipsis.displayName = "BreadcrumbElipssis" + +export { + Breadcrumb, + BreadcrumbList, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbPage, + BreadcrumbSeparator, + BreadcrumbEllipsis, +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..3b642df --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,69 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-lime-600 text-lime-100 hover:bg-lime-700", + brand: "bg-lime-600 text-lime-100 hover:bg-lime-700", + outline: + "border border-lime-600 bg-white hover:bg-lime-50 text-lime-600", + muted: "bg-lime-50 text-lime-800 hover:bg-lime-200 border border-transparent", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground border", + link: "text-primary underline-offset-4 hover:underline", + + lime: "bg-lime-600 text-lime-100 hover:bg-lime-800", + blue: "bg-blue-950 text-blue-100 hover:bg-blue-900", + amber: "bg-amber-600 text-amber-100 hover:bg-amber-700", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-8 rounded-md px-3", + lg: "h-14 rounded-md px-8 text-lg", + icon: "h-10 w-10", + }, + rounded: { + default: "rounded-md", + full: "rounded-full", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + rounded: "default", + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes<HTMLButtonElement>, + VariantProps<typeof buttonVariants> { + asChild?: boolean; +} + +const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( + ({ className, variant, rounded, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + <Comp + className={cn( + buttonVariants({ variant, size, rounded, className }), + )} + ref={ref} + {...props} + /> + ); + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..fbe4077 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,79 @@ +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-lg border bg-card text-card-foreground shadow-xs", + className + )} + {...props} + /> +)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn("flex flex-col space-y-1.5 p-6", className)} + {...props} + /> +)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes<HTMLHeadingElement> +>(({ className, ...props }, ref) => ( + <h3 + ref={ref} + className={cn( + "text-2xl font-semibold leading-none tracking-tight", + className + )} + {...props} + /> +)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes<HTMLParagraphElement> +>(({ className, ...props }, ref) => ( + <p + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> +)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => ( + <div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> +)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn("flex items-center p-6 pt-0", className)} + {...props} + /> +)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/src/components/ui/carousel.tsx b/src/components/ui/carousel.tsx new file mode 100644 index 0000000..ec505d0 --- /dev/null +++ b/src/components/ui/carousel.tsx @@ -0,0 +1,262 @@ +"use client" + +import * as React from "react" +import useEmblaCarousel, { + type UseEmblaCarouselType, +} from "embla-carousel-react" +import { ArrowLeft, ArrowRight } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +type CarouselApi = UseEmblaCarouselType[1] +type UseCarouselParameters = Parameters<typeof useEmblaCarousel> +type CarouselOptions = UseCarouselParameters[0] +type CarouselPlugin = UseCarouselParameters[1] + +type CarouselProps = { + opts?: CarouselOptions + plugins?: CarouselPlugin + orientation?: "horizontal" | "vertical" + setApi?: (api: CarouselApi) => void +} + +type CarouselContextProps = { + carouselRef: ReturnType<typeof useEmblaCarousel>[0] + api: ReturnType<typeof useEmblaCarousel>[1] + scrollPrev: () => void + scrollNext: () => void + canScrollPrev: boolean + canScrollNext: boolean +} & CarouselProps + +const CarouselContext = React.createContext<CarouselContextProps | null>(null) + +function useCarousel() { + const context = React.useContext(CarouselContext) + + if (!context) { + throw new Error("useCarousel must be used within a <Carousel />") + } + + return context +} + +const Carousel = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> & CarouselProps +>( + ( + { + orientation = "horizontal", + opts, + setApi, + plugins, + className, + children, + ...props + }, + ref + ) => { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === "horizontal" ? "x" : "y", + }, + plugins + ) + const [canScrollPrev, setCanScrollPrev] = React.useState(false) + const [canScrollNext, setCanScrollNext] = React.useState(false) + + const onSelect = React.useCallback((api: CarouselApi) => { + if (!api) { + return + } + + setCanScrollPrev(api.canScrollPrev()) + setCanScrollNext(api.canScrollNext()) + }, []) + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev() + }, [api]) + + const scrollNext = React.useCallback(() => { + api?.scrollNext() + }, [api]) + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent<HTMLDivElement>) => { + if (event.key === "ArrowLeft") { + event.preventDefault() + scrollPrev() + } else if (event.key === "ArrowRight") { + event.preventDefault() + scrollNext() + } + }, + [scrollPrev, scrollNext] + ) + + React.useEffect(() => { + if (!api || !setApi) { + return + } + + setApi(api) + }, [api, setApi]) + + React.useEffect(() => { + if (!api) { + return + } + + onSelect(api) + api.on("reInit", onSelect) + api.on("select", onSelect) + + return () => { + api?.off("select", onSelect) + } + }, [api, onSelect]) + + return ( + <CarouselContext.Provider + value={{ + carouselRef, + api: api, + opts, + orientation: + orientation || (opts?.axis === "y" ? "vertical" : "horizontal"), + scrollPrev, + scrollNext, + canScrollPrev, + canScrollNext, + }} + > + <div + ref={ref} + onKeyDownCapture={handleKeyDown} + className={cn("relative", className)} + role="region" + aria-roledescription="carousel" + {...props} + > + {children} + </div> + </CarouselContext.Provider> + ) + } +) +Carousel.displayName = "Carousel" + +const CarouselContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => { + const { carouselRef, orientation } = useCarousel() + + return ( + <div ref={carouselRef} className="overflow-hidden"> + <div + ref={ref} + className={cn( + "flex", + orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", + className + )} + {...props} + /> + </div> + ) +}) +CarouselContent.displayName = "CarouselContent" + +const CarouselItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => { + const { orientation } = useCarousel() + + return ( + <div + ref={ref} + role="group" + aria-roledescription="slide" + className={cn( + "min-w-0 shrink-0 grow-0 basis-full", + orientation === "horizontal" ? "pl-4" : "pt-4", + className + )} + {...props} + /> + ) +}) +CarouselItem.displayName = "CarouselItem" + +const CarouselPrevious = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<typeof Button> +>(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollPrev, canScrollPrev } = useCarousel() + + return ( + <Button + ref={ref} + variant={variant} + size={size} + className={cn( + "absolute h-8 w-8 rounded-full", + orientation === "horizontal" + ? "-left-12 top-1/2 -translate-y-1/2" + : "-top-12 left-1/2 -translate-x-1/2 rotate-90", + className + )} + disabled={!canScrollPrev} + onClick={scrollPrev} + {...props} + > + <ArrowLeft className="h-4 w-4" /> + <span className="sr-only">Previous slide</span> + </Button> + ) +}) +CarouselPrevious.displayName = "CarouselPrevious" + +const CarouselNext = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<typeof Button> +>(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + <Button + ref={ref} + variant={variant} + size={size} + className={cn( + "absolute h-8 w-8 rounded-full", + orientation === "horizontal" + ? "-right-12 top-1/2 -translate-y-1/2" + : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", + className + )} + disabled={!canScrollNext} + onClick={scrollNext} + {...props} + > + <ArrowRight className="h-4 w-4" /> + <span className="sr-only">Next slide</span> + </Button> + ) +}) +CarouselNext.displayName = "CarouselNext" + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..0b958a1 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,122 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef<typeof DialogPrimitive.Overlay>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> +>(({ className, ...props }, ref) => ( + <DialogPrimitive.Overlay + ref={ref} + className={cn( + "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + className + )} + {...props} + /> +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef<typeof DialogPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> +>(({ className, children, ...props }, ref) => ( + <DialogPortal> + <DialogOverlay /> + <DialogPrimitive.Content + ref={ref} + className={cn( + "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", + className + )} + {...props} + > + {children} + <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> + <X className="h-4 w-4" /> + <span className="sr-only">Close</span> + </DialogPrimitive.Close> + </DialogPrimitive.Content> + </DialogPortal> +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn( + "flex flex-col space-y-1.5 text-center sm:text-left", + className + )} + {...props} + /> +) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn( + "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", + className + )} + {...props} + /> +) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef<typeof DialogPrimitive.Title>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> +>(({ className, ...props }, ref) => ( + <DialogPrimitive.Title + ref={ref} + className={cn( + "text-lg font-semibold leading-none tracking-tight", + className + )} + {...props} + /> +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef<typeof DialogPrimitive.Description>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> +>(({ className, ...props }, ref) => ( + <DialogPrimitive.Description + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..1057af8 --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,200 @@ +"use client" + +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + <DropdownMenuPrimitive.SubTrigger + ref={ref} + className={cn( + "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent data-[state=open]:bg-accent", + inset && "pl-8", + className + )} + {...props} + > + {children} + <ChevronRight className="ml-auto h-4 w-4" /> + </DropdownMenuPrimitive.SubTrigger> +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> +>(({ className, ...props }, ref) => ( + <DropdownMenuPrimitive.SubContent + ref={ref} + className={cn( + "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className + )} + {...props} + /> +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> +>(({ className, sideOffset = 4, ...props }, ref) => ( + <DropdownMenuPrimitive.Portal> + <DropdownMenuPrimitive.Content + ref={ref} + sideOffset={sideOffset} + className={cn( + "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className + )} + {...props} + /> + </DropdownMenuPrimitive.Portal> +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + <DropdownMenuPrimitive.Item + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50", + inset && "pl-8", + className + )} + {...props} + /> +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> +>(({ className, children, checked, ...props }, ref) => ( + <DropdownMenuPrimitive.CheckboxItem + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50", + className + )} + checked={checked} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <DropdownMenuPrimitive.ItemIndicator> + <Check className="h-4 w-4" /> + </DropdownMenuPrimitive.ItemIndicator> + </span> + {children} + </DropdownMenuPrimitive.CheckboxItem> +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> +>(({ className, children, ...props }, ref) => ( + <DropdownMenuPrimitive.RadioItem + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50", + className + )} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <DropdownMenuPrimitive.ItemIndicator> + <Circle className="h-2 w-2 fill-current" /> + </DropdownMenuPrimitive.ItemIndicator> + </span> + {children} + </DropdownMenuPrimitive.RadioItem> +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.Label>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + <DropdownMenuPrimitive.Label + ref={ref} + className={cn( + "px-2 py-1.5 text-sm font-semibold", + inset && "pl-8", + className + )} + {...props} + /> +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> +>(({ className, ...props }, ref) => ( + <DropdownMenuPrimitive.Separator + ref={ref} + className={cn("-mx-1 my-1 h-px bg-muted", className)} + {...props} + /> +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes<HTMLSpanElement>) => { + return ( + <span + className={cn("ml-auto text-xs tracking-widest opacity-60", className)} + {...props} + /> + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx new file mode 100644 index 0000000..d0130c9 --- /dev/null +++ b/src/components/ui/form.tsx @@ -0,0 +1,179 @@ +"use client"; + +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import { + Controller, + type ControllerProps, + type FieldPath, + type FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form"; + +import { cn } from "@/lib/utils"; +import { Label } from "@/components/ui/label"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, +> = { + name: TName; +}; + +const FormFieldContext = React.createContext<FormFieldContextValue>( + {} as FormFieldContextValue, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, +>({ + ...props +}: ControllerProps<TFieldValues, TName>) => { + return ( + <FormFieldContext.Provider value={{ name: props.name }}> + <Controller {...props} /> + </FormFieldContext.Provider> + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within <FormField>"); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext<FormItemContextValue>( + {} as FormItemContextValue, +); + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + <FormItemContext.Provider value={{ id }}> + <div ref={ref} className={cn("space-y-2", className)} {...props} /> + </FormItemContext.Provider> + ); +}); +FormItem.displayName = "FormItem"; + +const FormLabel = React.forwardRef< + React.ElementRef<typeof LabelPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( + <Label + ref={ref} + className={cn(error && "text-destructive", className)} + htmlFor={formItemId} + {...props} + /> + ); +}); +FormLabel.displayName = "FormLabel"; + +const FormControl = React.forwardRef< + React.ElementRef<typeof Slot>, + React.ComponentPropsWithoutRef<typeof Slot> +>(({ ...props }, ref) => { + const { error, formItemId, formDescriptionId, formMessageId } = + useFormField(); + + return ( + <Slot + ref={ref} + id={formItemId} + aria-describedby={ + !error + ? `${formDescriptionId}` + : `${formDescriptionId} ${formMessageId}` + } + aria-invalid={!!error} + {...props} + /> + ); +}); +FormControl.displayName = "FormControl"; + +const FormDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes<HTMLParagraphElement> +>(({ className, ...props }, ref) => { + const { formDescriptionId } = useFormField(); + + return ( + <p + ref={ref} + id={formDescriptionId} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> + ); +}); +FormDescription.displayName = "FormDescription"; + +const FormMessage = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes<HTMLParagraphElement> +>(({ className, children, ...props }, ref) => { + const { error, formMessageId } = useFormField(); + const body = error ? String(error?.message) : children; + + if (!body) { + return null; + } + + return ( + <p + ref={ref} + id={formMessageId} + className={cn("text-sm font-medium text-destructive", className)} + {...props} + > + {body} + </p> + ); +}); +FormMessage.displayName = "FormMessage"; + +export { + useFormField, + Form, + FormItem, + FormLabel, + FormControl, + FormDescription, + FormMessage, + FormField, +}; diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 0000000..b819538 --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,25 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +export interface InputProps + extends React.InputHTMLAttributes<HTMLInputElement> {} + +const Input = React.forwardRef<HTMLInputElement, InputProps>( + ({ className, type, ...props }, ref) => { + return ( + <input + type={type} + className={cn( + "border-input bg-background placeholder:text-muted-foreground flex h-10 w-full rounded-md border-2 px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus:border-lime-500 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50", + className, + )} + ref={ref} + {...props} + /> + ); + }, +); +Input.displayName = "Input"; + +export { Input }; diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 0000000..70026d6 --- /dev/null +++ b/src/components/ui/label.tsx @@ -0,0 +1,26 @@ +"use client"; + +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const labelVariants = cva( + "text-sm font-medium tracking-wider font-zinc-700 leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", +); + +const Label = React.forwardRef< + React.ElementRef<typeof LabelPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & + VariantProps<typeof labelVariants> +>(({ className, ...props }, ref) => ( + <LabelPrimitive.Root + ref={ref} + className={cn(labelVariants(), className)} + {...props} + /> +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx new file mode 100644 index 0000000..4353cf7 --- /dev/null +++ b/src/components/ui/sheet.tsx @@ -0,0 +1,140 @@ +"use client" + +import * as React from "react" +import * as SheetPrimitive from "@radix-ui/react-dialog" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Sheet = SheetPrimitive.Root + +const SheetTrigger = SheetPrimitive.Trigger + +const SheetClose = SheetPrimitive.Close + +const SheetPortal = SheetPrimitive.Portal + +const SheetOverlay = React.forwardRef< + React.ElementRef<typeof SheetPrimitive.Overlay>, + React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay> +>(({ className, ...props }, ref) => ( + <SheetPrimitive.Overlay + className={cn( + "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + className + )} + {...props} + ref={ref} + /> +)) +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName + +const sheetVariants = cva( + "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", + { + variants: { + side: { + top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", + bottom: + "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", + right: + "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", + }, + }, + defaultVariants: { + side: "right", + }, + } +) + +interface SheetContentProps + extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, + VariantProps<typeof sheetVariants> {} + +const SheetContent = React.forwardRef< + React.ElementRef<typeof SheetPrimitive.Content>, + SheetContentProps +>(({ side = "right", className, children, ...props }, ref) => ( + <SheetPortal> + <SheetOverlay /> + <SheetPrimitive.Content + ref={ref} + className={cn(sheetVariants({ side }), className)} + {...props} + > + {children} + <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"> + <X className="h-4 w-4" /> + <span className="sr-only">Close</span> + </SheetPrimitive.Close> + </SheetPrimitive.Content> + </SheetPortal> +)) +SheetContent.displayName = SheetPrimitive.Content.displayName + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn( + "flex flex-col space-y-2 text-center sm:text-left", + className + )} + {...props} + /> +) +SheetHeader.displayName = "SheetHeader" + +const SheetFooter = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn( + "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", + className + )} + {...props} + /> +) +SheetFooter.displayName = "SheetFooter" + +const SheetTitle = React.forwardRef< + React.ElementRef<typeof SheetPrimitive.Title>, + React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title> +>(({ className, ...props }, ref) => ( + <SheetPrimitive.Title + ref={ref} + className={cn("text-lg font-semibold text-foreground", className)} + {...props} + /> +)) +SheetTitle.displayName = SheetPrimitive.Title.displayName + +const SheetDescription = React.forwardRef< + React.ElementRef<typeof SheetPrimitive.Description>, + React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description> +>(({ className, ...props }, ref) => ( + <SheetPrimitive.Description + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> +)) +SheetDescription.displayName = SheetPrimitive.Description.displayName + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx new file mode 100644 index 0000000..49a309f --- /dev/null +++ b/src/components/ui/sonner.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { Toaster as Sonner } from "sonner"; + +type ToasterProps = React.ComponentProps<typeof Sonner>; + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme(); + + return ( + <Sonner + theme={theme as ToasterProps["theme"]} + className="toaster group" + position="top-center" + toastOptions={{ + classNames: { + toast: "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg", + description: "group-[.toast]:text-muted-foreground", + actionButton: + "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground", + cancelButton: + "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground", + }, + }} + {...props} + /> + ); +}; + +export { Toaster }; diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx new file mode 100644 index 0000000..61a00de --- /dev/null +++ b/src/components/ui/textarea.tsx @@ -0,0 +1,24 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +export interface TextareaProps + extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {} + +const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( + ({ className, ...props }, ref) => { + return ( + <textarea + className={cn( + "border-input bg-background placeholder:text-muted-foreground flex min-h-20 w-full rounded-md border-2 px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus:border-lime-500 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50", + className, + )} + ref={ref} + {...props} + /> + ); + }, +); +Textarea.displayName = "Textarea"; + +export { Textarea }; diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 0000000..6885dfa --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1,8 @@ +/// <reference types="astro/client" /> + +declare var myString: string; +declare function myFunction(): boolean; + +declare function gtag_report_conversion(): (url?: string) => boolean; +declare function dataLayer(): any[]; +declare function gtag(): (...args: any[]) => void; diff --git a/src/globals.css b/src/globals.css new file mode 100644 index 0000000..d1c630d --- /dev/null +++ b/src/globals.css @@ -0,0 +1,132 @@ +@import "tailwindcss"; + +@custom-variant dark (&:is(.dark *)); + +@font-face { + font-family: "Sen"; + src: url("/fonts/sen-variable.ttf") format("truetype"); +} + +html { + scroll-behavior: smooth; +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.21 0.006 285.885); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.705 0.015 286.067); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.21 0.006 285.885); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.705 0.015 286.067); +} + +.dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.92 0.004 286.32); + --primary-foreground: oklch(0.21 0.006 285.885); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.552 0.016 285.938); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.552 0.016 285.938); +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} + +* { + font-family: "Sen"; +} diff --git a/src/lib/case.studies.ts b/src/lib/case.studies.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/lib/constants.ts b/src/lib/constants.ts new file mode 100644 index 0000000..6aebf65 --- /dev/null +++ b/src/lib/constants.ts @@ -0,0 +1,45 @@ +export const TRANSITION_COLORS = "transition-colors duration-150"; +export const TRANSITION_ALL = "transition-all duration-150"; + +export const navLinks = [ + { href: "/", label: "Home" }, + { href: "/#services", label: "Services" }, + { href: "/#packages", label: "Packages" }, +]; + +export const ADDRESS = "5000 Lake RD N STE A Keizer, OR 97000 USA"; + +// Default phone number +export const DEFAULT_PHONE_NO = "+1 (855) 669-6510"; + +// Page-specific phone numbers +export const PHONE_NUMBERS = { + default: DEFAULT_PHONE_NO, + spectrum: "+1 (833) 301-3193", + optimum: DEFAULT_PHONE_NO, + verizon: "+1 (888) 369-8670", +}; + +// Function to get phone number based on page +export function getPhoneNumber(page?: string): string { + if (!page) return DEFAULT_PHONE_NO; + return ( + PHONE_NUMBERS[page as keyof typeof PHONE_NUMBERS] || DEFAULT_PHONE_NO + ); +} + +// Create contact links with page-specific numbers +export function getContactLink(page?: string): string { + return "tel:" + getPhoneNumber(page); +} + +// Legacy support +export const CONTACT_PHONE_NO = DEFAULT_PHONE_NO; +export const CONTACT_LINK = getContactLink(); + +export const contactLinks = { + spectrum: getContactLink("spectrum"), + optimum: getContactLink("optimum"), + verizon: getContactLink("verizon"), + directtv: getContactLink(), +}; diff --git a/src/lib/data.types.ts b/src/lib/data.types.ts new file mode 100644 index 0000000..8c38632 --- /dev/null +++ b/src/lib/data.types.ts @@ -0,0 +1,3 @@ +export type Error = { message: string; error?: Error }; +export type Errors = Array<Error>; +export type Result<T> = { data?: T; errors?: Errors }; diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..e644794 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/src/pages/index.astro b/src/pages/index.astro new file mode 100644 index 0000000..cc4c954 --- /dev/null +++ b/src/pages/index.astro @@ -0,0 +1,694 @@ +--- +import "@/globals.css"; +import "aos/dist/aos.css"; + +import MaxWidthWrapper from "@/components/other/max.width.wrapper"; +import Navbar from "@/components/molecules/navbar"; +import Footer from "@/components/molecules/footer.astro"; +import Title from "@/components/atoms/title"; +import GridPattern from "@/components/atoms/grid.pattern"; +import { Button } from "@/components/ui/button"; +import Stats from "@/components/Stats.astro"; +import Testimonials from "@/components/Testimonials.astro"; +import { cn } from "@/lib/utils"; +import { CONTACT_LINK } from "@/lib/constants"; +import { + Wifi, + Tv, + PhoneCall, + Shield, + Zap, + Globe, + HeartHandshake, + Headphones, +} from "lucide-react"; +import CallNowDialog from "@/components/CallNowDialog"; +import CallButton from "@/components/molecules/CallButton"; + +const page = "default"; +--- + +<html lang="en"> + <head> + <meta charset="utf-8" /> + <meta + name="viewport" + content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" + /> + + <meta + name="description" + content="NetFundable - Your trusted source for the best telecom deals. We connect you with top providers including Spectrum, Optimum, and DirectTV to bring you unbeatable offers on Internet, TV, and Phone services." + /> + <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> + <title>NetFundable - Connecting You to the Best Telecom Deals + + + + + + +
+
+ + +
+
+
+ YOUR TRUSTED TELECOM CONNECTION PARTNER +
+ + <p class="text-lime-100 mt-2"> + We connect you with top providers at exclusive + rates. No contracts, no hidden fees, and + personalized service. + </p> + </div> + <div class="flex flex-col sm:flex-row gap-4"> + <a href={CONTACT_LINK}> + <Button + size={"lg"} + className="w-full sm:w-max" + variant="lime" + > + Call Now + </Button> + </a> + <a href={"#our-partners"}> + <Button + size={"lg"} + className="w-full sm:w-max" + variant={"muted"} + > + View Providers + </Button> + </a> + </div> + </div> + <img + src="/assets/hero-image.png" + alt="NetFundable services illustration" + class="w-full h-full object-contain max-w-lg lg:max-w-none" + width={300} + height={500} + data-aos="fade-up" + data-aos-duration="500" + /> + </MaxWidthWrapper> + + <!-- Wave divider --> + <div class="absolute bottom-0 left-0 w-full"> + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 1440 320" + class="w-full h-auto" + > + <path + fill="#ffffff" + fill-opacity="1" + d="M0,128L48,144C96,160,192,192,288,197.3C384,203,480,181,576,170.7C672,160,768,160,864,170.7C960,181,1056,203,1152,197.3C1248,192,1344,160,1392,144L1440,128L1440,320L1392,320C1344,320,1248,320,1152,320C1056,320,960,320,864,320C768,320,672,320,576,320C480,320,384,320,288,320C192,320,96,320,48,320L0,320Z" + ></path> + </svg> + </div> + </div> + </div> + + <!-- About Section --> + <MaxWidthWrapper id="about" className="py-24"> + <div class="grid grid-cols-1 md:grid-cols-2 gap-16 items-center"> + <div data-aos="fade-right" data-aos-duration="500"> + <img + src="/assets/about-image.png" + alt="About NetFundable" + class="rounded-lg w-full h-auto" + /> + </div> + <div + class="flex flex-col gap-6" + data-aos="fade-left" + data-aos-duration="500" + > + <span class="text-lime-600 font-semibold">ABOUT US</span> + <Title + title="Your Connection to Better Telecom Deals" + size="h2" + color="primary" + /> + <p class="text-gray-700 text-lg"> + At NetFundable, we simplify the complex world of + telecommunications by connecting consumers with the + perfect services at the best possible rates. + </p> + <p class="text-gray-700"> + As authorized retailers for leading providers like + Spectrum, Optimum, and DirectTV, we leverage our + industry relationships to secure exclusive deals you + won't find elsewhere. Our mission is to ensure you get + the most value for your money while enjoying top-quality + service. + </p> + <div class="grid grid-cols-2 gap-4 mt-4"> + <div class="flex items-center gap-2"> + <div class="bg-lime-100 p-2 rounded-full"> + <HeartHandshake + size={20} + className="text-lime-600" + /> + </div> + <span class="font-medium">8+ Years Experience</span> + </div> + <div class="flex items-center gap-2"> + <div class="bg-lime-100 p-2 rounded-full"> + <Headphones + size={20} + className="text-lime-600" + /> + </div> + <span class="font-medium">Expert Support</span> + </div> + <div class="flex items-center gap-2"> + <div class="bg-lime-100 p-2 rounded-full"> + <Zap size={20} className="text-lime-600" /> + </div> + <span class="font-medium">Fast Connection</span> + </div> + <div class="flex items-center gap-2"> + <div class="bg-lime-100 p-2 rounded-full"> + <Globe size={20} className="text-lime-600" /> + </div> + <span class="font-medium">Nationwide Coverage</span> + </div> + </div> + </div> + </div> + </MaxWidthWrapper> + + <!-- Services Section --> + <div class="bg-lime-50 py-24"> + <MaxWidthWrapper id="services"> + <div class="text-center mb-16"> + <span class="text-lime-600 font-semibold">OUR SERVICES</span + > + <Title + title="Comprehensive Telecom Solutions" + size="h2" + color="primary" + /> + <p class="max-w-2xl mx-auto mt-4 text-gray-700"> + We offer a complete range of telecommunication services + to meet all your connectivity and entertainment needs. + </p> + </div> + + <div + class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8" + > + <div + class="bg-white p-8 rounded-xl shadow-sm border border-gray-100 flex flex-col items-center text-center" + data-aos="fade-up" + data-aos-duration="400" + > + <div class="bg-lime-100 p-4 rounded-full mb-4"> + <Wifi className="h-8 w-8 text-lime-600" /> + </div> + <h3 class="text-xl font-bold mb-2"> + High-Speed Internet + </h3> + <p class="text-gray-600"> + Blazing-fast fiber and cable internet options with + speeds up to 1 Gbps for seamless streaming, gaming, + and browsing. + </p> + </div> + + <div + class="bg-white p-8 rounded-xl shadow-sm border border-gray-100 flex flex-col items-center text-center" + data-aos="fade-up" + data-aos-duration="500" + > + <div class="bg-lime-100 p-4 rounded-full mb-4"> + <Tv className="h-8 w-8 text-lime-600" /> + </div> + <h3 class="text-xl font-bold mb-2"> + Premium TV Packages + </h3> + <p class="text-gray-600"> + Access to hundreds of HD channels, exclusive sports + content, and on-demand entertainment from top + providers. + </p> + </div> + + <div + class="bg-white p-8 rounded-xl shadow-sm border border-gray-100 flex flex-col items-center text-center" + data-aos="fade-up" + data-aos-duration="600" + > + <div class="bg-lime-100 p-4 rounded-full mb-4"> + <PhoneCall className="h-8 w-8 text-lime-600" /> + </div> + <h3 class="text-xl font-bold mb-2">Voice Services</h3> + <p class="text-gray-600"> + Reliable home phone service with crystal-clear + calling, voicemail, and dozens of features at + competitive rates. + </p> + </div> + + <div + class="bg-white p-8 rounded-xl shadow-sm border border-gray-100 flex flex-col items-center text-center" + data-aos="fade-up" + data-aos-duration="700" + > + <div class="bg-lime-100 p-4 rounded-full mb-4"> + <Shield className="h-8 w-8 text-lime-600" /> + </div> + <h3 class="text-xl font-bold mb-2"> + Security Solutions + </h3> + <p class="text-gray-600"> + Protect your digital life with advanced network + security, parental controls, and identity protection + services. + </p> + </div> + </div> + + <div class="text-center mt-12"> + <a href={CONTACT_LINK}> + <Button variant="lime" size="lg"> + Explore All Services + </Button> + </a> + </div> + </MaxWidthWrapper> + </div> + + <!-- Stats Section --> + <div class="py-24"> + <Stats /> + </div> + + <!-- Provider Highlights --> + <MaxWidthWrapper className="py-24" id="packages"> + <div class="text-center mb-16"> + <span class="text-lime-600 font-semibold"> + FEATURED PROVIDERS + </span> + <Title + title="Exclusive Offers from Top Providers" + size="h2" + color="primary" + /> + <p class="max-w-2xl mx-auto mt-4 text-gray-700"> + Discover our hand-picked selection of the best telecom deals + currently available. + </p> + </div> + + <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"> + <!-- Spectrum Card --> + <div + class="bg-white rounded-xl overflow-hidden shadow-md border border-gray-200" + data-aos="fade-up" + data-aos-duration="400" + > + <div + class="h-48 bg-white flex items-center justify-center p-8" + > + <img + src="/assets/spectrum.png" + alt="Spectrum" + class="h-20 w-auto object-contain" + /> + </div> + <div class="p-6"> + <p class="text-gray-600 mb-4"> + High-speed internet with no data caps, HD TV with + free DVR service, and reliable home phone. + </p> + <ul class="space-y-2 mb-6"> + <li class="flex items-center gap-2"> + <div + class="h-5 w-5 rounded-full bg-lime-100 flex items-center justify-center" + > + <div + class="h-2.5 w-2.5 rounded-full bg-lime-600" + > + </div> + </div> + <span>Internet speeds up to 1 Gbps</span> + </li> + <li class="flex items-center gap-2"> + <div + class="h-5 w-5 rounded-full bg-lime-100 flex items-center justify-center" + > + <div + class="h-2.5 w-2.5 rounded-full bg-lime-600" + > + </div> + </div> + <span>125+ HD channels</span> + </li> + <li class="flex items-center gap-2"> + <div + class="h-5 w-5 rounded-full bg-lime-100 flex items-center justify-center" + > + <div + class="h-2.5 w-2.5 rounded-full bg-lime-600" + > + </div> + </div> + <span>No contracts required</span> + </li> + </ul> + <a href="/spectrum"> + <Button variant="lime">View Spectrum Deals</Button> + </a> + </div> + </div> + + <!-- Optimum Card --> + <div + class="bg-white rounded-xl overflow-hidden shadow-md border border-gray-200" + data-aos="fade-up" + data-aos-duration="500" + > + <div + class="h-48 bg-white flex items-center justify-center p-8" + > + <img + src="/assets/optimum.png" + alt="Optimum" + class="h-20 w-auto object-contain" + /> + </div> + <div class="p-6"> + <p class="text-gray-600 mb-4"> + Fiber internet, crystal-clear TV service, and mobile + plans with nationwide coverage. + </p> + <ul class="space-y-2 mb-6"> + <li class="flex items-center gap-2"> + <div + class="h-5 w-5 rounded-full bg-amber-100 flex items-center justify-center" + > + <div + class="h-2.5 w-2.5 rounded-full bg-amber-600" + > + </div> + </div> + <span>Fiber internet up to 1 Gig</span> + </li> + <li class="flex items-center gap-2"> + <div + class="h-5 w-5 rounded-full bg-amber-100 flex items-center justify-center" + > + <div + class="h-2.5 w-2.5 rounded-full bg-amber-600" + > + </div> + </div> + <span>Free WiFi 6 equipment</span> + </li> + <li class="flex items-center gap-2"> + <div + class="h-5 w-5 rounded-full bg-amber-100 flex items-center justify-center" + > + <div + class="h-2.5 w-2.5 rounded-full bg-amber-600" + > + </div> + </div> + <span>12-month price guarantee</span> + </li> + </ul> + <a href="/optimum"> + <Button variant="lime" class="w-full" + >View Optimum Deals</Button + > + </a> + </div> + </div> + + <!-- Verizon Card --> + <div + class="bg-white rounded-xl overflow-hidden shadow-md border border-gray-200" + data-aos="fade-up" + data-aos-duration="600" + > + <div + class="h-48 bg-white flex items-center justify-center p-8" + > + <img + src="/assets/verizon.png" + alt="Verizon" + class="h-20 w-auto object-contain" + /> + </div> + <div class="p-6"> + <p class="text-gray-600 mb-4"> + Fiber internet, TV, and phone service with + nationwide coverage. + </p> + <ul class="space-y-2 mb-6"> + <li class="flex items-center gap-2"> + <div + class="h-5 w-5 rounded-full bg-amber-100 flex items-center justify-center" + > + <div + class="h-2.5 w-2.5 rounded-full bg-amber-600" + > + </div> + </div> + <span>Fiber internet up to 1 Gig</span> + </li> + <li class="flex items-center gap-2"> + <div + class="h-5 w-5 rounded-full bg-amber-100 flex items-center justify-center" + > + <div + class="h-2.5 w-2.5 rounded-full bg-amber-600" + > + </div> + </div> + <span>Free WiFi 6 equipment</span> + </li> + <li class="flex items-center gap-2"> + <div + class="h-5 w-5 rounded-full bg-amber-100 flex items-center justify-center" + > + <div + class="h-2.5 w-2.5 rounded-full bg-amber-600" + > + </div> + </div> + <span>12-month price guarantee</span> + </li> + </ul> + <a href="/verizon"> + <Button variant="lime">View Verizon Deals</Button> + </a> + </div> + </div> + </div> + </MaxWidthWrapper> + + <!-- Testimonials Section --> + <div class="py-24 bg-gray-50"> + <Testimonials + variant="lime" + testimonials={[ + { + name: "Michael T.", + image: "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?q=80&w=2787&auto=format&fit=crop", + stars: 5, + review: "NetFundable made switching providers so easy. They found me a Spectrum package that saved me over $40 a month compared to my previous provider, and the installation was completely hassle-free.", + }, + { + name: "Jessica L.", + image: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?q=80&w=2787&auto=format&fit=crop", + stars: 5, + review: "I was overwhelmed by all the options until I called NetFundable. Their representative took the time to understand my needs and recommended the perfect Optimum package for my family. Couldn't be happier!", + }, + { + name: "Robert K.", + image: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?q=80&w=3387&auto=format&fit=crop", + stars: 5, + review: "After my frustrating experience dealing directly with providers, working with NetFundable was a breath of fresh air. They handled everything from finding the best deal to coordinating installation. Truly excellent service.", + }, + ]} + /> + </div> + + <!-- CTA Section --> + <div class="bg-lime-900 py-20"> + <MaxWidthWrapper> + <div class="text-center"> + <Title + title="Ready to get connected?" + size="h2" + color="white" + /> + <p class="text-lime-100 mt-4 mb-8 max-w-2xl mx-auto"> + Speak with our telecom experts today and discover the + perfect package for your home at the best possible + price. No obligation, just honest advice. + </p> + + <div class="flex flex-col sm:flex-row gap-4 justify-center"> + <CallButton + client:load + variant="lime" + fullWidth={false} + text="Call Now For Exclusive Deals" + page={page} + /> + <a href="#our-partners"> + <Button + variant="muted" + size="lg" + className="w-full sm:w-auto" + > + Browse Providers + </Button> + </a> + </div> + + <p class="text-lime-200 mt-6"> + No contracts • 30-day money-back guarantee • Local + support + </p> + </div> + </MaxWidthWrapper> + </div> + + <!-- FAQ Section --> + <MaxWidthWrapper className="py-24"> + <div class="text-center mb-16"> + <span class="text-lime-600 font-semibold" + >FREQUENTLY ASKED QUESTIONS</span + > + <Title + title="Common Questions About Our Services" + size="h2" + color="primary" + /> + </div> + + <div class="max-w-3xl mx-auto divide-y divide-gray-200"> + <div class="py-6" data-aos="fade-up" data-aos-duration="300"> + <h3 class="text-lg font-medium text-gray-900"> + How does NetFundable work? + </h3> + <p class="mt-2 text-gray-600"> + We're authorized retailers for major telecom providers, + allowing us to offer their services at special rates. We + handle everything from recommending the best package to + coordinating installation, acting as your personal + telecom concierge. + </p> + </div> + <div class="py-6" data-aos="fade-up" data-aos-duration="400"> + <h3 class="text-lg font-medium text-gray-900"> + Are there any fees for using NetFundable's services? + </h3> + <p class="mt-2 text-gray-600"> + No! Our service is completely free for customers. We're + compensated by the providers we partner with, so there's + never any additional cost to you for working with us. + </p> + </div> + <div class="py-6" data-aos="fade-up" data-aos-duration="500"> + <h3 class="text-lg font-medium text-gray-900"> + What areas do you service? + </h3> + <p class="mt-2 text-gray-600"> + We currently offer services in most states across the + U.S. through our partnerships with national and regional + providers. Contact us with your address, and we'll + confirm availability in your specific location. + </p> + </div> + <div class="py-6" data-aos="fade-up" data-aos-duration="600"> + <h3 class="text-lg font-medium text-gray-900"> + Can I bundle multiple services? + </h3> + <p class="mt-2 text-gray-600"> + Absolutely! Bundling internet, TV, phone, or mobile + services often provides the best value. Our experts can + recommend the ideal bundle based on your usage patterns + and preferences. + </p> + </div> + <div class="py-6" data-aos="fade-up" data-aos-duration="700"> + <h3 class="text-lg font-medium text-gray-900"> + What happens after I place an order? + </h3> + <p class="mt-2 text-gray-600"> + Once you select a package, we'll coordinate with the + provider to schedule installation or activation. We'll + keep you updated throughout the process and remain + available for any questions even after your service is + active. + </p> + </div> + </div> + </MaxWidthWrapper> + + <Footer variant="lime" page={page} /> + </body> +</html> + +<script> + import AOS from "aos"; + + AOS.init({ + duration: 800, + easing: "ease-in-out", + once: true, + startEvent: "DOMContentLoaded", + disableMutationObserver: false, + // Fix for width issue + disable: false, + mirror: false, + // These are the critical parameters for the width issue + useClassNames: true, + initClassName: "aos-init", + animatedClassName: "aos-animate", + }); + + // Force viewport refresh + document.addEventListener("DOMContentLoaded", () => { + // Force a layout recalculation + document.body.style.width = "100vw"; + document.body.offsetHeight; + document.body.style.width = ""; + + // Refresh AOS + AOS.refresh(); + }); +</script> diff --git a/src/pages/optimum/index.astro b/src/pages/optimum/index.astro new file mode 100644 index 0000000..4e356b1 --- /dev/null +++ b/src/pages/optimum/index.astro @@ -0,0 +1,253 @@ +--- +import "@/globals.css"; +import "aos/dist/aos.css"; + +import ServicesSection from "@/components/ServicesSection.astro"; +import WhyChooseUs from "@/components/WhyChooseUs.astro"; +import Packages from "@/components/Packages.astro"; +import Testimonials from "@/components/Testimonials.astro"; +import MaxWidthWrapper from "@/components/other/max.width.wrapper"; +import Title from "@/components/atoms/title"; +import Navbar from "@/components/molecules/navbar"; +import Footer from "@/components/molecules/footer.astro"; +import { Wifi, Tv, PhoneCall, Shield } from "lucide-react"; +import CallButton from "@/components/molecules/CallButton"; + +const services = [ + { + id: 1, + icon: Wifi, + color: "primary", + title: "Optimum Fiber", + description: + "State-of-the-art fiber connectivity with speeds reaching 1 Gig, including complimentary next-gen WiFi equipment. Enjoy consistent performance throughout your entire residence.", + aos: { + "data-aos": "zoom-out", + "data-aos-duration": "600", + }, + }, + { + id: 2, + icon: Tv, + color: "secondary", + title: "Optimum TV", + description: + "Explore over 420 channels, ultra-high-definition content, and digital recording capabilities. Stream your entertainment on multiple devices with the dedicated Optimum TV application.", + aos: { + "data-aos": "zoom-out", + "data-aos-duration": "500", + }, + }, + { + id: 3, + icon: PhoneCall, + color: "tertiary", + title: "Optimum Mobile", + description: + "Unlimited 5G connectivity on a dependable nationwide network. Support for multiple lines with flexible plan options starting at just $15 monthly.", + aos: { + "data-aos": "zoom-out", + "data-aos-duration": "500", + }, + }, + { + id: 4, + icon: Shield, + color: "quaternary", + title: "Intelligent WiFi", + description: + "Comprehensive network protection, customizable content filtering, and performance optimization tools. Control your home connectivity remotely through the intuitive Optimum mobile application.", + aos: { + "data-aos": "zoom-out", + "data-aos-duration": "600", + }, + }, +]; + +const page = "optimum"; +--- + +<html lang="en"> + <head> + <!-- <script + async + src="https://www.googletagmanager.com/gtag/js?id=AW-" + is:inline></script> + + <script is:inline> + window.dataLayer = window.dataLayer || []; + window.gtag = function () { + dataLayer.push(arguments); + }; + window.gtag("js", new Date()); + window.gtag("config", "AW-"); + + window.gtag_report_conversion = function (url) { + var callback = function () { + if (typeof url != "undefined") { + window.location = url; + } + }; + window.gtag("event", "conversion", { + send_to: "AW-/wL44CK2dva0aEJX-4_s-", + value: 1.0, + currency: "USD", + event_callback: callback, + }); + return false; + }; + </script> --> + + <script + async + is:inline + src="https://www.googletagmanager.com/gtag/js?id=AW-16902586133" + ></script> + <script is:inline> + window.dataLayer = window.dataLayer || []; + window.gtag = function () { + dataLayer.push(arguments); + }; + window.gtag("js", new Date()); + window.gtag("config", "AW-16982616626"); + </script> + + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width" /> + <meta + name="description" + content="Discover exclusive Optimum deals through NetFundable. Access superior fiber internet, premium TV packages, and mobile services with exceptional promotions and dedicated support." + /> + <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> + <title>Premium Optimum Services - NetFundable + + + + +
+
+ +
+ + <Title title="Optimum Fiber Internet" size="h3" /> + <Title + title="Complimentary Gateway | Included WiFi Extender | Gigabit Capability" + size="h5" + weight="medium" + /> + <Title title="Optimum TV" size="h3" /> + <Title + title="Premium picture quality | Vast On-Demand selection" + size="h5" + weight="medium" + /> + <Title title="Optimum Mobile" size="h3" /> + <Title + title="Unlimited data plan | Coast-to-coast coverage | Latest 5G technology" + size="h5" + weight="medium" + /> + <Title + title="NO LONG-TERM COMMITMENTS | 12-MONTH PRICING GUARANTEE" + size="h4" + /> + + <CallButton + client:load + variant={"lime"} + text="Activate Your Service" + page={page} + /> + </div> + </MaxWidthWrapper> + </div> + + <ServicesSection variant="lime" services={services} /> + <WhyChooseUs + variant="lime" + logo="/assets/optimum.png" + title="Why Choose Optimum With NetFundable?" + description="Harness the potential of Optimum's cutting-edge fiber infrastructure and comprehensive digital entertainment solutions through NetFundable. We streamline the process of securing optimal rates on Optimum's premium services while offering dedicated assistance throughout your customer journey." + benefits={[ + "12-Month Rate Protection", + "Complimentary Expert Installation", + "Top-Tier Equipment Included", + "Transparent Pricing Structure", + ]} + page={page} + /> + <Packages variant="lime" page={page} /> + <Testimonials + variant="lime" + testimonials={[ + { + name: "Adrian M.", + image: "https://images.unsplash.com/photo-1539571696357-5a69c17a67c6?q=80&w=2662&auto=format&fit=crop", + stars: 5, + review: "Optimum's fiber connection delivers exceptional performance! I consistently receive the advertised speeds, and their latest wireless equipment provides flawless coverage throughout my home. The price guarantee gives me confidence in my budget planning.", + }, + { + name: "Natalie J.", + image: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?q=80&w=2787&auto=format&fit=crop", + stars: 5, + review: "Converting to Optimum significantly enhanced our digital lifestyle. The technicians completed the installation efficiently, and bundling with their mobile service has generated substantial savings compared to our previous providers.", + }, + { + name: "Marcus L.", + image: "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?q=80&w=2787&auto=format&fit=crop", + stars: 5, + review: "Optimum's standout quality is their exceptional customer experience. When I needed to modify my service package, their representatives provided clear information and helped identify the ideal configuration for my requirements. Their fiber network performance exceeds expectations.", + }, + ]} + /> + + <MaxWidthWrapper> + <div + class="p-6 md:p-8 flex flex-col gap-4 rounded-md bg-linear-to-br from-lime-600 to-lime-900 drop-shadow-lg md:items-center md:text-center" + > + <Title + title="Revolutionize Your Digital Lifestyle with Fiber Technology" + size="h1" + color="washedWhite" + /> + <p class="text-base md:text-lg md:text-center text-blue-50"> + Enjoy uninterrupted streaming, gaming, and productivity + with our dependable fiber network. With speeds reaching + 1 Gigabit, included equipment, and flexible commitment + options, Optimum delivers superior connectivity. Enhance + your savings by bundling with TV and mobile services! + </p> + <div class="flex flex-col items-center gap-2"> + <strong class="text-lg md:text-xl text-white"> + CONTACT US NOW FOR SPECIAL PRICING AND PACKAGE + OPTIONS + </strong> + + <CallButton client:load variant={"lime"} page={page} /> + </div> + </div> + </MaxWidthWrapper> + </div> + + <Footer variant="lime" page={page} /> + </body> +</html> + +<script> + import AOS from "aos"; + + AOS.init({ + duration: 800, + easing: "ease-in-out", + once: true, + }); +</script> diff --git a/src/pages/spectrum/index.astro b/src/pages/spectrum/index.astro new file mode 100644 index 0000000..3485658 --- /dev/null +++ b/src/pages/spectrum/index.astro @@ -0,0 +1,252 @@ +--- +import "@/globals.css"; +import "aos/dist/aos.css"; + +import ServicesSection from "@/components/ServicesSection.astro"; +import WhyChooseUs from "@/components/WhyChooseUs.astro"; +import Packages from "@/components/Packages.astro"; +import Testimonials from "@/components/Testimonials.astro"; +import MaxWidthWrapper from "@/components/other/max.width.wrapper"; +import Title from "@/components/atoms/title"; +import Navbar from "@/components/molecules/navbar"; +import Footer from "@/components/molecules/footer.astro"; +import { Wifi, Tv, PhoneCall, Shield } from "lucide-react"; +import CallButton from "@/components/molecules/CallButton"; + +const services = [ + { + id: 1, + icon: Wifi, + color: "primary", + title: "Spectrum Internet", + description: + "Elevate your online experience with lightning-fast speeds reaching 1 Gbps without data limitations. Includes complimentary security software, ideal for streaming in 4K, online gaming, and remote working.", + aos: { + "data-aos": "zoom-out", + "data-aos-duration": "600", + }, + }, + { + id: 2, + icon: Tv, + color: "secondary", + title: "Spectrum TV", + description: + "Access over 200 high-definition channels, a vast library of On Demand content, and the convenient Spectrum TV App. Stream your favorite content on any device, anywhere.", + aos: { + "data-aos": "zoom-out", + "data-aos-duration": "500", + }, + }, + { + id: 3, + icon: PhoneCall, + color: "tertiary", + title: "Spectrum Voice", + description: + "Experience exceptional call clarity with over 28 premium features including voicemail, call waiting, and robocall blocking. Enjoy unlimited nationwide calling without any hidden charges.", + aos: { + "data-aos": "zoom-out", + "data-aos-duration": "500", + }, + }, + { + id: 4, + icon: Shield, + color: "quaternary", + title: "Security Suite", + description: + "Safeguard your digital presence with Spectrum's comprehensive security package featuring real-time threat monitoring, secure WiFi connections, and customizable parental control options.", + aos: { + "data-aos": "zoom-out", + "data-aos-duration": "600", + }, + }, +]; + +const page = "spectrum"; +--- + +<html lang="en"> + <head> + <!-- <script + async + src="https://www.googletagmanager.com/gtag/js?id=AW-16902586133" + is:inline></script> + <script is:inline> + window.dataLayer = window.dataLayer || []; + window.gtag = function () { + dataLayer.push(arguments); + }; + window.gtag("js", new Date()); + window.gtag("config", "AW-16902586133"); + + window.gtag_report_conversion = function (url) { + var callback = function () { + if (typeof url != "undefined") { + window.location = url; + } + }; + window.gtag("event", "conversion", { + send_to: "AW-16902586133/wL44CK2dva0aEJX-4_s-", + value: 1.0, + currency: "USD", + event_callback: callback, + }); + return false; + }; + </script> --> + + <script + async + src="https://www.googletagmanager.com/gtag/js?id=AW-16982616626" + is:inline></script> + <script is:inline> + window.dataLayer = window.dataLayer || []; + window.gtag = function () { + dataLayer.push(arguments); + }; + window.gtag("js", new Date()); + window.gtag("config", "AW-16982616626"); + </script> + + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width" /> + <meta + name="description" + content="Discover exceptional Spectrum deals through NetFundable. Enjoy premium Internet, TV, and Phone services with exclusive promotions and personalized support." + /> + <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> + <title>Exclusive Spectrum Offers - NetFundable + + + + +
+
+ +
+ + <Title title="Spectrum TV" size="h3" /> + <Title + title="HD Included | Extensive On Demand Library | Affordable Billing" + size="h5" + weight="medium" + /> + <Title title="Spectrum Internet" size="h3" /> + <Title + title="Complimentary Modem | Unlimited Data | Budget-Friendly Rates" + size="h5" + weight="medium" + /> + <Title title="Spectrum Voice" size="h3" /> + <Title + title="UNLIMITED calls with transparent billing across U.S., Canada & Mexico" + size="h5" + weight="medium" + /> + <Title + title="CONTRACT-FREE | 30-DAY SATISFACTION GUARANTEE | SIMPLIFIED BILLING" + size="h4" + /> + + <CallButton + client:load + variant={"lime"} + text="Connect Today" + page={page} + /> + </div> + </MaxWidthWrapper> + </div> + + <ServicesSection variant="lime" services={services} page={page} /> + <WhyChooseUs + variant="lime" + logo="/assets/spectrum.png" + title="Why Choose Spectrum Through NetFundable?" + description="Experience industry-leading bandwidth, pristine high-definition programming, and dependable service quality with Spectrum. Our exclusive partnership delivers premium promotions, hassle-free activation, and straightforward billing with no hidden fees, all powered by Spectrum's cutting-edge network infrastructure." + benefits={[ + "Zero Contract Obligations with Flexible Billing", + "30-Day Money-Back Guarantee", + "Transparent Pricing & Billing Structure", + "Included Equipment & HD Programming at No Extra Cost", + ]} + page={page} + /> + <Packages variant="lime" page={page} /> + <Testimonials + variant="lime" + testimonials={[ + { + name: "Rebecca T.", + image: "https://images.unsplash.com/photo-1534528741775-53994a69daeb?q=80&w=3276&auto=format&fit=crop", + stars: 5, + review: "Transitioning to Spectrum transformed our household entertainment experience. The connection remains stable even during peak usage hours, and their HD picture quality is exceptional. Customer support always resolves our questions promptly.", + }, + { + name: "Thomas G.", + image: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?q=80&w=3387&auto=format&fit=crop", + stars: 5, + review: "As a loyal Spectrum subscriber for nearly 3 years, I'm continually impressed by their consistent service quality. Their bundled packages provide exceptional value, and the network performance stays consistent regardless of how many devices we connect.", + }, + { + name: "Sophia R.", + image: "https://images.unsplash.com/photo-1580489944761-15a19d654956?q=80&w=3361&auto=format&fit=crop", + stars: 5, + review: "The Spectrum installation team was efficient and knowledgeable. The unlimited data allowance is perfect for our streaming habits, and accessing content through their mobile app is remarkably convenient. The absence of long-term commitments is a significant advantage!", + }, + ]} + /> + + <MaxWidthWrapper> + <div + class="p-6 md:p-8 flex flex-col gap-4 rounded-md bg-linear-to-br from-lime-100 via-lime-50 to-lime-200 border-lime-600 border-2 drop-shadow-lg md:items-center text-center" + > + <Title + title="Complete connectivity for the modern household at a price you'll love." + size="h1" + /> + <p class="text-base md:text-lg md:text-center"> + Experience seamless entertainment across all your + devices, from smart TVs to tablets. Enjoy enhanced + online performance whether browsing, streaming, or + gaming. Stay connected with loved ones through + crystal-clear voice communications spanning the United + States and beyond. All this with predictable monthly + billing, no contractual commitments, and no early + termination penalties! + </p> + <div class="flex flex-col items-center gap-2"> + <strong class="text-lg md:text-xl"> + CALL NOW FOR YOUR EXCLUSIVE OFFER AND SIMPLIFIED + BILLING OPTIONS + </strong> + + <CallButton client:load variant={"lime"} page={page} /> + </div> + </div> + </MaxWidthWrapper> + </div> + <Footer variant="lime" page={page} /> + </body> +</html> + +<script> + import AOS from "aos"; + + AOS.init({ + duration: 800, + easing: "ease-in-out", + once: true, + }); +</script> diff --git a/src/pages/verizon/index.astro b/src/pages/verizon/index.astro new file mode 100644 index 0000000..e137f03 --- /dev/null +++ b/src/pages/verizon/index.astro @@ -0,0 +1,224 @@ +--- +import "@/globals.css"; +import "aos/dist/aos.css"; + +import ServicesSection from "@/components/ServicesSection.astro"; +import WhyChooseUs from "@/components/WhyChooseUs.astro"; +import Packages from "@/components/Packages.astro"; +import Testimonials from "@/components/Testimonials.astro"; +import MaxWidthWrapper from "@/components/other/max.width.wrapper"; +import Title from "@/components/atoms/title"; +import Navbar from "@/components/molecules/navbar"; +import Footer from "@/components/molecules/footer.astro"; +import { Wifi, Tv, PhoneCall, Shield } from "lucide-react"; +import CallButton from "@/components/molecules/CallButton"; + +const services = [ + { + id: 1, + icon: Wifi, + color: "primary", + title: "Verizon Fios Internet", + description: + "Experience the power of 100% fiber-optic internet with symmetrical upload and download speeds up to 940 Mbps. Perfect for 4K streaming, competitive gaming, and whole-home connectivity with no data caps.", + aos: { + "data-aos": "zoom-out", + "data-aos-duration": "600", + }, + }, + { + id: 2, + icon: Tv, + color: "secondary", + title: "Verizon Fios TV", + description: + "Enjoy crystal-clear picture quality with 425+ channels, including premium options and integrated streaming services. The Fios TV app lets you watch your favorite shows anywhere on any device.", + aos: { + "data-aos": "zoom-out", + "data-aos-duration": "500", + }, + }, + { + id: 3, + icon: PhoneCall, + color: "tertiary", + title: "Verizon Home Phone", + description: + "Stay connected with reliable home phone service featuring 20+ advanced calling features, including caller ID, call waiting, and spam protection. Unlimited nationwide calling with crystal-clear connections.", + aos: { + "data-aos": "zoom-out", + "data-aos-duration": "500", + }, + }, + { + id: 4, + icon: Shield, + color: "quaternary", + title: "Whole-Home Network", + description: + "Elevate your home network with Verizon's advanced router technology, featuring WiFi 6E capability, parental controls, and built-in network security. Enjoy seamless coverage throughout your entire home.", + aos: { + "data-aos": "zoom-out", + "data-aos-duration": "600", + }, + }, +]; + +const page = "verizon"; +--- + +<html lang="en"> + <head> + <!-- <script + async + src="https://www.googletagmanager.com/gtag/js?id=AW-16982616626" + is:inline></script> + <script is:inline> + window.dataLayer = window.dataLayer || []; + window.gtag = function () { + dataLayer.push(arguments); + }; + window.gtag("js", new Date()); + window.gtag("config", "AW-16982616626"); + </script> --> + + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width" /> + <meta + name="description" + content="Discover exclusive Verizon Fios deals through NetFundable. Experience 100% fiber-optic internet, premium TV, and reliable home phone service with special promotions and expert support." + /> + <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> + <title>Premium Verizon Fios Services - NetFundable + + + + +
+
+ +
+ + <Title title="Verizon Fios Internet" size="h3" /> + <Title + title="Symmetrical Speeds | No Data Caps | Transparent Billing" + size="h5" + weight="medium" + /> + <Title title="Verizon Fios TV" size="h3" /> + <Title + title="425+ Channels | 4K Ultra HD | Cost-Effective Packages" + size="h5" + weight="medium" + /> + <Title title="Verizon Home Phone" size="h3" /> + <Title + title="Crystal-clear calling | Advanced features | Affordable rates" + size="h5" + weight="medium" + /> + <Title + title="PRICE GUARANTEE | SIMPLIFIED BILLING | NO HIDDEN FEES" + size="h4" + /> + + <CallButton + client:load + variant={"lime"} + text="Get Connected Today" + page={page} + /> + </div> + </MaxWidthWrapper> + </div> + + <ServicesSection variant="lime" services={services} page={page} /> + <WhyChooseUs + variant="lime" + logo="/assets/verizon.png" + title="Why Choose Verizon Fios Through NetFundable?" + description="Discover the unmatched reliability and performance of Verizon's 100% fiber-optic network with NetFundable. We provide exclusive access to Verizon's premium services at special rates with straightforward billing options, and personalized support throughout your entire customer journey." + benefits={[ + "2-Year Price Guarantee with Consistent Billing", + "Expert Installation Included at No Extra Cost", + "Premium Router Technology with No Rental Fees", + "Ranked #1 in Customer Satisfaction and Billing Clarity", + ]} + page={page} + /> + <Packages variant="lime" page={page} /> + <Testimonials + variant="lime" + testimonials={[ + { + name: "Daniel R.", + image: "https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?q=80&w=2787&auto=format&fit=crop", + stars: 5, + review: "Switching to Verizon Fios has transformed my work-from-home experience. The symmetrical speeds mean my video conferences never buffer, and uploading large files is just as fast as downloading. The reliability is simply unmatched.", + }, + { + name: "Olivia M.", + image: "https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?q=80&w=2376&auto=format&fit=crop", + stars: 5, + review: "As a family with multiple gamers and streamers, our previous internet couldn't keep up. Since getting Fios, everyone can do their thing simultaneously with zero slowdowns. The WiFi coverage reaches every corner of our home!", + }, + { + name: "Jason K.", + image: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?q=80&w=2370&auto=format&fit=crop", + stars: 5, + review: "The installation was smooth and professional. The technician explained everything clearly and even helped optimize our home network setup. Two years later, we've never experienced an outage, and the speeds are consistently as advertised.", + }, + ]} + /> + + <MaxWidthWrapper> + <div + class="p-6 md:p-8 flex flex-col gap-4 rounded-md bg-linear-to-br from-lime-600 to-lime-900 drop-shadow-lg md:items-center md:text-center" + > + <Title + title="Complete connectivity for the modern household at a price you'll love." + size="h1" + /> + <p class="text-base md:text-lg md:text-center text-lime-50"> + Verizon's dedicated fiber-optic connection delivers + unparalleled reliability, ultra-fast speeds, and + crystal-clear entertainment. With symmetrical upload and + download capabilities, whole-home WiFi coverage, and a + 2-year price guarantee with consistent billing, Fios + provides the stable foundation your digital life + deserves without the surprise costs. + </p> + <div class="flex flex-col items-center gap-2"> + <strong class="text-lg md:text-xl text-white"> + CONTACT US NOW FOR EXCLUSIVE PRICING AND SIMPLIFIED + BILLING OPTIONS + </strong> + + <CallButton client:load variant={"lime"} page={page} /> + </div> + </div> + </MaxWidthWrapper> + </div> + + <Footer variant="lime" page={page} /> + </body> +</html> + +<script> + import AOS from "aos"; + + AOS.init({ + duration: 800, + easing: "ease-in-out", + once: true, + }); +</script> diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ec34c6f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "astro/tsconfigs/strict", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "react", + "baseUrl": "./src", + "paths": { "@/*": ["*"] }, + "skipLibCheck": true + } + +}