commit fd1b32640af0ffca1cfa88d941c678e4219cca05 Author: Rami Bitar Date: Sun Oct 4 13:16:12 2026 -0400 Initial commit of vite-saas template Co-Authored-By: Claude Opus 5.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3afb834 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.DS_Store +node_modules/* +dist +.output +.nitro +.vercel +.yarn/install-state.gz +.env*.local +src/routeTree.gen.ts +*.tsbuildinfo + +.tanstack diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..3186f3f --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/README.md b/README.md new file mode 100644 index 0000000..0286856 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# Vite SaaS + +A SaaS marketing site built with Vite, TanStack Start, Tailwind CSS v4 and shadcn/ui. +Ported from `nextjs-templates/nextjs-saas`. + +Runs in the nodesandbox runtime: `npm install` → `npm run dev` → port 3000. + +## Getting started + +```sh +corepack enable # uses the Yarn 4 pinned in package.json +yarn install +yarn dev # http://localhost:3000 +``` + +| Script | What it does | +| ---------------- | --------------------------------------------------------- | +| `yarn dev` | Vite dev server with SSR | +| `yarn build` | Production build; Nitro packages the server for the host | +| `yarn start` | Run the Node build from `.output/` | +| `yarn typecheck` | `tsc` | + +## Deploying to Vercel + +Push and deploy — no dashboard settings. `vercel.json` selects the TanStack +Start preset and installs with Yarn 4 via Corepack; Nitro writes +`.vercel/output/` during `vite build`. + +## nodesandbox compatibility + +The sandbox runs Vite's native tools as WebAssembly (`NAPI_RS_FORCE_WASI`), and +its installer resolves from `package.json` ranges rather than `yarn.lock`. The +toolchain versions match `vite-shopify-storefront`: + +- `vite` is pinned to **8.3.2** (rolldown 1.2.x WASI binding). +- WASI bindings (`@rolldown/binding-wasm32-wasi`, `@tailwindcss/oxide-wasm32-wasi`) + are **not** listed: the runtime installs each package's `*-wasm32-wasi` + optional dependency itself, and npm on other hosts rejects them. +- `@vitejs/plugin-react` (6.0.0) and Tailwind (4.3.1) match the harness samples. +- Nitro (the hosting adapter) only loads for `vite build`, never in `vite dev`. + +## Layout + +``` +src/ + router.tsx Router setup + routes/ File routes (__root.tsx is the document shell, index.tsx the page) + components/ Sections, layout and shadcn/ui components + lib/site.ts Site name, copy and links + styles.css Tailwind v4 theme +public/ Static assets served from / +``` diff --git a/components.json b/components.json new file mode 100644 index 0000000..c6a8ac1 --- /dev/null +++ b/components.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": { + "@magicui": "https://magicui.design/r/{name}", + "@ai-elements": "https://ai-sdk.dev/elements/api/registry/{name}.json" + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9d945f9 --- /dev/null +++ b/package.json @@ -0,0 +1,51 @@ +{ + "name": "vite-saas", + "type": "module", + "private": true, + "version": "0.0.0", + "scripts": { + "dev": "vite dev --host 0.0.0.0 --port 3000", + "build": "vite build", + "start": "node .output/server/index.mjs", + "typecheck": "tsc --noEmit" + }, + "packageManager": "yarn@4.18.0+sha512.fcb8716fe7cd0eece141ffc18b92193a9df9204c1ba83189c288835223fc0bbe64af473bab0d5e9927a7daeb5caf2bb07eb2787cc9338ca040ea125f2a1f2f7e", + "dependencies": { + "@base-ui/react": "^1.6.0", + "@remixicon/react": "^4.9.0", + "@shadcn/react": "^0.1.0", + "@tanstack/react-router": "^1.170.41", + "@tanstack/react-start": "^1.168.60", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "cobe": "^0.6.4", + "date-fns": "^4.4.0", + "embla-carousel-react": "^8.6.0", + "input-otp": "^1.4.2", + "lucide-react": "^1.22.0", + "motion": "^13.3.0", + "next-themes": "^0.4.6", + "nitro": "3.0.260903-beta", + "radix-ui": "^1.6.0", + "react": "^19.3.0", + "react-day-picker": "^10.0.1", + "react-dom": "^19.3.0", + "react-resizable-panels": "^4.12.0", + "shadcn": "^4.12.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.6.0", + "tw-animate-css": "^1.4.0", + "vaul": "^1.1.2" + }, + "devDependencies": { + "@tailwindcss/vite": "4.3.1", + "@types/node": "^26.6.4", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "6.0.0", + "tailwindcss": "4.3.1", + "typescript": "^5.7.3", + "vite": "8.3.2" + } +} diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..427de68 --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/ai-elements/snippet.tsx b/src/components/ai-elements/snippet.tsx new file mode 100644 index 0000000..a7664f1 --- /dev/null +++ b/src/components/ai-elements/snippet.tsx @@ -0,0 +1,143 @@ +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, + InputGroupText, +} from "@/components/ui/input-group"; +import { cn } from "@/lib/utils"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import type { ComponentProps } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +interface SnippetContextType { + code: string; +} + +const SnippetContext = createContext({ + code: "", +}); + +export type SnippetProps = ComponentProps & { + code: string; +}; + +export const Snippet = ({ + code, + className, + children, + ...props +}: SnippetProps) => { + const contextValue = useMemo(() => ({ code }), [code]); + + return ( + + + {children} + + + ); +}; + +export type SnippetAddonProps = ComponentProps; + +export const SnippetAddon = (props: SnippetAddonProps) => ( + +); + +export type SnippetTextProps = ComponentProps; + +export const SnippetText = ({ className, ...props }: SnippetTextProps) => ( + +); + +export type SnippetInputProps = Omit< + ComponentProps, + "readOnly" | "value" +>; + +export const SnippetInput = ({ className, ...props }: SnippetInputProps) => { + const { code } = useContext(SnippetContext); + + return ( + + ); +}; + +export type SnippetCopyButtonProps = ComponentProps & { + onCopy?: () => void; + onError?: (error: Error) => void; + timeout?: number; +}; + +export const SnippetCopyButton = ({ + onCopy, + onError, + timeout = 2000, + children, + className, + ...props +}: SnippetCopyButtonProps) => { + const [isCopied, setIsCopied] = useState(false); + const timeoutRef = useRef(0); + const { code } = useContext(SnippetContext); + + const copyToClipboard = useCallback(async () => { + if (typeof window === "undefined" || !navigator?.clipboard?.writeText) { + onError?.(new Error("Clipboard API not available")); + return; + } + + try { + if (!isCopied) { + await navigator.clipboard.writeText(code); + setIsCopied(true); + onCopy?.(); + timeoutRef.current = window.setTimeout( + () => setIsCopied(false), + timeout + ); + } + } catch (error) { + onError?.(error as Error); + } + }, [code, onCopy, onError, timeout, isCopied]); + + useEffect( + () => () => { + window.clearTimeout(timeoutRef.current); + }, + [] + ); + + const Icon = isCopied ? CheckIcon : CopyIcon; + + return ( + + {children ?? } + + ); +}; diff --git a/src/components/layout/mobile-navigation.tsx b/src/components/layout/mobile-navigation.tsx new file mode 100644 index 0000000..d812d87 --- /dev/null +++ b/src/components/layout/mobile-navigation.tsx @@ -0,0 +1,128 @@ +import { useEffect, useRef, useState } from 'react'; +import { RiCloseLine, RiMenuLine } from '@remixicon/react'; + +import { Brand } from '@/components/shared/brand'; +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetTitle, + SheetTrigger, +} from '@/components/ui/sheet'; +import { siteConfig, type NavigationItem } from '@/lib/site'; + +export function MobileNavigation({ items }: { items: NavigationItem[] }) { + const [open, setOpen] = useState(false); + const navigating = useRef(false); + + useEffect(() => { + const desktop = window.matchMedia('(min-width: 768px)'); + const closeOnDesktop = () => { + if (desktop.matches) setOpen(false); + }; + + desktop.addEventListener('change', closeOnDesktop); + return () => desktop.removeEventListener('change', closeOnDesktop); + }, []); + + return ( + + + + + + { + // Let the anchor move focus to its section without the trigger + // interrupting the page's smooth scroll when the sheet closes. + if (navigating.current) event.preventDefault(); + navigating.current = false; + }} + > +
+ + + + +
+ + + + +
+
+ ); +} diff --git a/src/components/layout/site-footer.tsx b/src/components/layout/site-footer.tsx new file mode 100644 index 0000000..4f736ff --- /dev/null +++ b/src/components/layout/site-footer.tsx @@ -0,0 +1,63 @@ +import { RiGithubFill, RiTwitterXFill } from '@remixicon/react'; + +import { Brand } from '@/components/shared/brand'; +import { Container } from '@/components/shared/container'; +import { footerColumns, siteConfig } from '@/lib/site'; + +const socialLinks = [ + { label: 'GitHub', href: siteConfig.links.github, Icon: RiGithubFill }, + { label: 'X (Twitter)', href: siteConfig.links.twitter, Icon: RiTwitterXFill }, +]; + +export function SiteFooter() { + return ( +
+ +
+
+ +

+ {siteConfig.tagline} +

+
+ {footerColumns.map((column) => ( +
+

+ {column.heading} +

+ +
+ ))} +
+
+ + © {new Date().getFullYear()} {siteConfig.name}, Inc. All rights reserved. + +
+ {socialLinks.map(({ label, href, Icon }) => ( + + + ))} +
+
+
+
+ ); +} diff --git a/src/components/layout/site-header.tsx b/src/components/layout/site-header.tsx new file mode 100644 index 0000000..58d530b --- /dev/null +++ b/src/components/layout/site-header.tsx @@ -0,0 +1,53 @@ +import { MobileNavigation } from '@/components/layout/mobile-navigation'; +import { ThemeToggle } from '@/components/layout/theme-toggle'; +import { Brand } from '@/components/shared/brand'; +import { Container } from '@/components/shared/container'; +import { Button } from '@/components/ui/button'; +import { navigationItems, siteConfig } from '@/lib/site'; + +export function SiteHeader() { + return ( +
+ +
+ + + + +
+
+ + + + +
+
+
+ ); +} diff --git a/src/components/layout/theme-provider.tsx b/src/components/layout/theme-provider.tsx new file mode 100644 index 0000000..2e1dd00 --- /dev/null +++ b/src/components/layout/theme-provider.tsx @@ -0,0 +1,9 @@ +import * as React from 'react'; +import { ThemeProvider as NextThemesProvider } from 'next-themes'; + +export function ThemeProvider({ + children, + ...props +}: React.ComponentProps) { + return {children}; +} diff --git a/src/components/layout/theme-toggle.tsx b/src/components/layout/theme-toggle.tsx new file mode 100644 index 0000000..666b3c8 --- /dev/null +++ b/src/components/layout/theme-toggle.tsx @@ -0,0 +1,41 @@ +import { useSyncExternalStore } from 'react'; +import { RiMoonLine, RiSunLine } from '@remixicon/react'; +import { useTheme } from 'next-themes'; + +import { Button } from '@/components/ui/button'; + +const emptySubscribe = () => () => {}; + +export function ThemeToggle() { + const mounted = useSyncExternalStore( + emptySubscribe, + () => true, + () => false, + ); + const { resolvedTheme, setTheme } = useTheme(); + + const isDark = mounted && resolvedTheme === 'dark'; + + return ( + + ); +} diff --git a/src/components/route-states.tsx b/src/components/route-states.tsx new file mode 100644 index 0000000..f10e016 --- /dev/null +++ b/src/components/route-states.tsx @@ -0,0 +1,34 @@ +import { Link } from '@tanstack/react-router'; + +// Stand-ins for Next's built-in 404 and error pages. + +export function NotFound() { + return ( +
+
+
404
+
+
+ This page could not be found.{' '} + + Go home + +
+
+
+ ); +} + +export function ErrorMessage({ error }: { error: unknown }) { + return ( +
+
+
Something went wrong
+
+
+ {error instanceof Error ? error.message : String(error)} +
+
+
+ ); +} diff --git a/src/components/sections/cta-section.tsx b/src/components/sections/cta-section.tsx new file mode 100644 index 0000000..4c00bc1 --- /dev/null +++ b/src/components/sections/cta-section.tsx @@ -0,0 +1,25 @@ +import { ActionButtons } from '@/components/shared/action-buttons'; +import { Container } from '@/components/shared/container'; +import { GradientGlow } from '@/components/shared/gradient-glow'; +import { SectionHeading } from '@/components/shared/section-heading'; + +export function CtaSection() { + return ( +
+ + + + + +
+ ); +} diff --git a/src/components/sections/features-section.tsx b/src/components/sections/features-section.tsx new file mode 100644 index 0000000..37fa5f9 --- /dev/null +++ b/src/components/sections/features-section.tsx @@ -0,0 +1,87 @@ +import { Container } from '@/components/shared/container'; +import { SectionHeading } from '@/components/shared/section-heading'; + +const features = [ + { + step: '01', + title: 'Build', + description: + 'Native support for 35+ frameworks. Push your code and Vertex handles the build pipeline, caching, and dependencies automatically.', + icon: , + }, + { + step: '02', + title: 'Preview', + description: + 'Every push gets a live preview URL. Share it, comment on it, and ship with confidence — no environment drift, no surprises.', + icon: ( + <> + + + + ), + }, + { + step: '03', + title: 'Ship', + description: + 'Deploy to a global edge network in seconds. Instant rollbacks, zero-downtime releases, and traffic that lands close to every user.', + icon: ( + + ), + }, +]; + +export function FeaturesSection() { + return ( +
+ + + +
+ {features.map((feature) => ( +
+
+ + + {feature.step} + +
+

{feature.title}

+

+ {feature.description} +

+
+ ))} +
+
+
+ ); +} diff --git a/src/components/sections/hero-section.tsx b/src/components/sections/hero-section.tsx new file mode 100644 index 0000000..dc5d743 --- /dev/null +++ b/src/components/sections/hero-section.tsx @@ -0,0 +1,82 @@ +import { + Snippet, + SnippetAddon, + SnippetCopyButton, + SnippetInput, + SnippetText, +} from '@/components/ai-elements/snippet'; +import { ActionButtons } from '@/components/shared/action-buttons'; +import { Container } from '@/components/shared/container'; +import { GradientGlow } from '@/components/shared/gradient-glow'; + +const hero = { + announcement: { label: 'Vertex Edge is now generally available', href: '#' }, + title: 'Build. Preview. Ship.', + description: + 'Vertex is the frontend cloud. Give your team the tooling and global infrastructure to build, preview, and ship a faster web — from your first commit to production traffic.', + primary: { label: 'Start Deploying', href: '#' }, + secondary: { label: 'Get a Demo', href: '#' }, + command: 'vertex deploy --prod', +}; + +function GridBackdrop() { + return ( +