initial commit
24
.gitignore
vendored
Normal file
@@ -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/
|
||||
4
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"recommendations": ["astro-build.astro-vscode"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
11
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"command": "./node_modules/.bin/astro dev",
|
||||
"name": "Development server",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
15
Dockerfile
Normal file
@@ -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"]
|
||||
14
astro.config.mjs
Normal file
@@ -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 })],
|
||||
});
|
||||
17
components.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
56
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
10
prettier.config.cjs
Normal file
@@ -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"],
|
||||
};
|
||||
BIN
public/assets/about-image.png
Normal file
|
After Width: | Height: | Size: 425 KiB |
BIN
public/assets/hero-image.png
Normal file
|
After Width: | Height: | Size: 455 KiB |
12
public/assets/logo.svg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/assets/map.png
Normal file
|
After Width: | Height: | Size: 438 KiB |
BIN
public/assets/optimum-banner.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
public/assets/optimum.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
public/assets/spectrum-banner.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
public/assets/spectrum.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
public/assets/testimonial-lady.png
Normal file
|
After Width: | Height: | Size: 259 KiB |
BIN
public/assets/verizon-banner.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/assets/verizon.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
4
public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100" height="100" rx="50" fill="#3F6212"/>
|
||||
<path d="M60.1875 50.5C60.1875 48.885 60.045 47.3175 59.855 45.75H67.8825C68.2625 47.27 68.5 48.8613 68.5 50.5C68.5 51.3788 68.4288 52.2337 68.31 53.065C69.9488 53.3025 71.4688 53.825 72.87 54.585C73.1075 53.255 73.25 51.9012 73.25 50.5C73.25 37.4375 62.5625 26.75 49.5 26.75C36.3663 26.75 25.75 37.4375 25.75 50.5C25.75 63.5625 36.4375 74.25 49.5 74.25C50.9013 74.25 52.255 74.1075 53.585 73.87C52.4569 71.8012 51.8688 69.4813 51.875 67.125C51.875 66.4363 51.9463 65.7712 52.0413 65.1062C51.2813 66.6025 50.45 68.0512 49.5 69.405C47.5288 66.555 45.9375 63.3962 44.9638 60H53.7988C55.2252 57.5213 57.3701 55.5338 59.95 54.3C60.0925 53.0413 60.1875 51.7825 60.1875 50.5ZM49.5 31.5713C51.4713 34.4213 53.0625 37.6038 54.0363 41H44.9638C45.9375 37.6038 47.5288 34.4213 49.5 31.5713ZM31.1175 55.25C30.7375 53.73 30.5 52.1387 30.5 50.5C30.5 48.8613 30.7375 47.27 31.1175 45.75H39.145C38.955 47.3175 38.8125 48.885 38.8125 50.5C38.8125 52.115 38.955 53.6825 39.145 55.25H31.1175ZM33.065 60H40C40.8313 62.9688 41.9 65.8187 43.325 68.455C38.9984 66.9642 35.3552 63.962 33.065 60ZM40 41H33.065C35.3418 37.0267 38.9898 34.0204 43.325 32.545C41.9 35.1813 40.8313 38.0312 40 41ZM55.0575 55.25H43.9425C43.705 53.6825 43.5625 52.115 43.5625 50.5C43.5625 48.885 43.705 47.2937 43.9425 45.75H55.0575C55.2713 47.2937 55.4375 48.885 55.4375 50.5C55.4375 52.115 55.2713 53.6825 55.0575 55.25ZM55.6513 32.545C60.0213 34.0413 63.655 37.0575 65.935 41H58.9288C58.1843 38.0588 57.0837 35.2195 55.6513 32.545ZM74.4375 62.9688L63.1563 74.25L56.625 67.125L59.38 64.37L63.1563 68.1463L71.6825 59.62L74.4375 62.9688Z" fill="#ECFCCB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
BIN
public/fonts/sen-variable.ttf
Normal file
4
public/logo.svg
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
52
src/components/CallNowDialog.tsx
Normal file
@@ -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 ? (
|
||||
<div
|
||||
className={cn(
|
||||
TRANSITION_ALL,
|
||||
open ? "fixed top-0 z-10000 h-screen w-screen" : "hidden",
|
||||
"grid place-items-center bg-lime-800/80",
|
||||
)}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center gap-8">
|
||||
<Title title="Call Now" size="h2" color="white" />
|
||||
|
||||
<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;
|
||||
77
src/components/HeroSection.astro
Normal file
@@ -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>
|
||||
14
src/components/OurPartners.astro
Normal file
@@ -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>
|
||||
116
src/components/Packages.astro
Normal file
@@ -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>
|
||||
32
src/components/Partners.tsx
Normal file
@@ -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;
|
||||
55
src/components/ServicesSection.astro
Normal file
@@ -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>
|
||||
67
src/components/Stats.astro
Normal file
@@ -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>
|
||||
84
src/components/Testimonials.astro
Normal file
@@ -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>
|
||||
115
src/components/WhyChooseUs.astro
Normal file
@@ -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>
|
||||
12
src/components/atoms/check.circle.icon.astro
Normal file
@@ -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>
|
||||
77
src/components/atoms/grid.pattern.tsx
Normal file
@@ -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;
|
||||
14
src/components/atoms/logo.tsx
Normal file
@@ -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;
|
||||
35
src/components/atoms/section.heading.tsx
Normal file
@@ -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;
|
||||
66
src/components/atoms/title.tsx
Normal file
@@ -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;
|
||||
51
src/components/molecules/CallButton.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
80
src/components/molecules/certification-details.tsx
Normal file
@@ -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;
|
||||
78
src/components/molecules/footer.astro
Normal file
@@ -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>
|
||||
124
src/components/molecules/navbar.tsx
Normal file
@@ -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;
|
||||
102
src/components/molecules/pricing.card.astro
Normal file
@@ -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>
|
||||
67
src/components/molecules/skill.card.tsx
Normal file
@@ -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;
|
||||
77
src/components/other/icons.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
55
src/components/other/marquee.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
26
src/components/other/max.width.wrapper.tsx
Normal file
@@ -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;
|
||||
280
src/components/other/particles.tsx
Normal file
@@ -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;
|
||||
72
src/components/pages/optimum/Pricing.astro
Normal file
@@ -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>
|
||||
75
src/components/pages/spectrum/Pricing.astro
Normal file
@@ -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>
|
||||
141
src/components/ui/alert-dialog.tsx
Normal file
@@ -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,
|
||||
}
|
||||
115
src/components/ui/breadcrumb.tsx
Normal file
@@ -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,
|
||||
}
|
||||
69
src/components/ui/button.tsx
Normal file
@@ -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 };
|
||||
79
src/components/ui/card.tsx
Normal file
@@ -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 }
|
||||
262
src/components/ui/carousel.tsx
Normal file
@@ -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,
|
||||
}
|
||||
122
src/components/ui/dialog.tsx
Normal file
@@ -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,
|
||||
}
|
||||
200
src/components/ui/dropdown-menu.tsx
Normal file
@@ -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,
|
||||
}
|
||||
179
src/components/ui/form.tsx
Normal file
@@ -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,
|
||||
};
|
||||
25
src/components/ui/input.tsx
Normal file
@@ -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 };
|
||||
26
src/components/ui/label.tsx
Normal file
@@ -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 };
|
||||
140
src/components/ui/sheet.tsx
Normal file
@@ -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,
|
||||
}
|
||||
31
src/components/ui/sonner.tsx
Normal file
@@ -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 };
|
||||
24
src/components/ui/textarea.tsx
Normal file
@@ -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 };
|
||||
8
src/env.d.ts
vendored
Normal file
@@ -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;
|
||||
132
src/globals.css
Normal file
@@ -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";
|
||||
}
|
||||
0
src/lib/case.studies.ts
Normal file
45
src/lib/constants.ts
Normal file
@@ -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(),
|
||||
};
|
||||
3
src/lib/data.types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type Error = { message: string; error?: Error };
|
||||
export type Errors = Array<Error>;
|
||||
export type Result<T> = { data?: T; errors?: Errors };
|
||||
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
694
src/pages/index.astro
Normal file
@@ -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</title>
|
||||
</head>
|
||||
<body>
|
||||
<Navbar client:load variant="lime" page={page} />
|
||||
<CallNowDialog client:load page={page} />
|
||||
|
||||
<!-- Hero Section with Animation -->
|
||||
<div class="flex flex-col gap-8 w-screen">
|
||||
<div
|
||||
class="min-h-screen h-full w-full bg-lime-900 pt-32 grid place-items-center pb-32 md:pb-0 relative overflow-hidden"
|
||||
>
|
||||
<GridPattern
|
||||
width={30}
|
||||
height={30}
|
||||
x={-1}
|
||||
y={-1}
|
||||
strokeDasharray={"4 2"}
|
||||
className={cn(
|
||||
"[mask-image:radial-gradient(80vw_circle_at_center,white,transparent)] absolute inset-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">
|
||||
YOUR TRUSTED TELECOM CONNECTION PARTNER
|
||||
</h6>
|
||||
<Title
|
||||
title="Get the Best Deals on Internet, TV & Phone Services"
|
||||
color="white"
|
||||
size="h1"
|
||||
/>
|
||||
<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>
|
||||
253
src/pages/optimum/index.astro
Normal file
@@ -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>
|
||||
</head>
|
||||
<body>
|
||||
<Navbar client:load variant="lime" page={page} />
|
||||
|
||||
<div class="w-screen overflow-x-hidden flex flex-col gap-32 lg:gap-64">
|
||||
<div
|
||||
class="bg-promo h-full w-full bg-cover bg-center"
|
||||
style="background-image: url('/assets/optimum-banner.png');"
|
||||
>
|
||||
<MaxWidthWrapper className="py-32 md:pt-40 xl:pt-56">
|
||||
<div
|
||||
class="flex flex-col gap-4 max-w-(--breakpoint-lg) w-full text-white"
|
||||
>
|
||||
<Title
|
||||
title="Unleash the Power of Next-Generation Fiber with Competitive Pricing"
|
||||
size="h1"
|
||||
color="washedWhite"
|
||||
/>
|
||||
<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>
|
||||
252
src/pages/spectrum/index.astro
Normal file
@@ -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>
|
||||
</head>
|
||||
<body>
|
||||
<Navbar client:load variant="lime" page={page} />
|
||||
|
||||
<div class="w-screen overflow-x-hidden flex flex-col gap-32 lg:gap-64">
|
||||
<div
|
||||
class="bg-promo h-full w-full bg-cover bg-center"
|
||||
style="background-image: url('/assets/spectrum-banner.png');"
|
||||
>
|
||||
<MaxWidthWrapper className="py-32 md:pt-40 xl:pt-56">
|
||||
<div
|
||||
class="flex flex-col gap-4 max-w-(--breakpoint-lg) w-full text-white"
|
||||
>
|
||||
<Title
|
||||
title="Transform Your Home with Next-Generation Connectivity at the Best Price"
|
||||
size="h1"
|
||||
color="washedWhite"
|
||||
/>
|
||||
<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>
|
||||
224
src/pages/verizon/index.astro
Normal file
@@ -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>
|
||||
</head>
|
||||
<body>
|
||||
<Navbar client:load variant="lime" page={page} />
|
||||
|
||||
<div class="w-screen overflow-x-hidden flex flex-col gap-32 lg:gap-64">
|
||||
<div
|
||||
class="bg-promo h-full w-full bg-cover bg-center"
|
||||
style="background-image: url('/assets/verizon-banner.png');"
|
||||
>
|
||||
<MaxWidthWrapper className="py-32 md:pt-40 xl:pt-56">
|
||||
<div
|
||||
class="flex flex-col gap-4 max-w-(--breakpoint-lg) w-full text-white"
|
||||
>
|
||||
<Title
|
||||
title="Experience the Power of 100% Fiber-Optic Technology with Predictable Billing"
|
||||
size="h1"
|
||||
color="washedWhite"
|
||||
/>
|
||||
<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>
|
||||
11
tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react",
|
||||
"baseUrl": "./src",
|
||||
"paths": { "@/*": ["*"] },
|
||||
"skipLibCheck": true
|
||||
}
|
||||
|
||||
}
|
||||