diff --git a/.env.example b/.env.example deleted file mode 100644 index 85788a0ba..000000000 --- a/.env.example +++ /dev/null @@ -1,32 +0,0 @@ -# Rename to .env - -### DEVELOPMENT ONLY VARIABLES -# These variables need to be set -# for local development only - -# Mandatory next-auth URL for localhost -NEXTAUTH_URL=http://app.localhost:3000 - -### PRODUCTION & DEVELOPMENT VARIABLES -# These variables need to be set -# for local development and when deployed on Vercel - -# MySQL database URL for Prisma -DATABASE_URL=mysql://root@127.0.0.1:3309/platforms - -# GitHub OAuth https://docs.github.com/en/developers/apps/building-oauth-apps/creating-an-oauth-app -GITHUB_ID= -GITHUB_SECRET= - -# Twitter Auth Bearer token (for static tweets) -TWITTER_AUTH_TOKEN= - -# Secret key (generate one here: https://generate-secret.vercel.app/32) -NEXTAUTH_SECRET= - -# https://vercel.com/account/tokens -AUTH_BEARER_TOKEN= -# https://vercel.com///settings -PROJECT_ID_VERCEL= -# https://vercel.com/teams//settings -TEAM_ID_VERCEL= diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index bffb357a7..000000000 --- a/.eslintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "next/core-web-vitals" -} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1 @@ + diff --git a/.github/workflows/npm-gulp.yml b/.github/workflows/npm-gulp.yml new file mode 100644 index 000000000..f8aa8bb2c --- /dev/null +++ b/.github/workflows/npm-gulp.yml @@ -0,0 +1,28 @@ +name: NodeJS with Gulp + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x, 20.x, 22.x] + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Build + run: | + npm install + gulp diff --git a/.gitignore b/.gitignore index aeb2b35f0..5ef6a5207 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,41 @@ -**/node_modules -**.next -**.env -**.DS_Store -**.vercel -**.git +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel .vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index d0679104b..000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "typescript.tsdk": "node_modules/typescript/lib", - "typescript.enablePromptUseWorkspaceTsdk": true -} \ No newline at end of file diff --git a/README.md b/README.md index 7d8cefcd5..69714e1d8 100644 --- a/README.md +++ b/README.md @@ -1,137 +1,90 @@ -

- - -

Platforms Starter Kit

- -

+# Next.js Multi-Tenant Example -

- The all-in-one starter kit
- for building platforms on Vercel. -

+A production-ready example of a multi-tenant application built with Next.js 15, featuring custom subdomains for each tenant. -

- Introduction · - Guide · - Demo · - Kitchen Sink · - Contributing -

-
+## Features -## Deploy Your Own +- ✅ Custom subdomain routing with Next.js middleware +- ✅ Tenant-specific content and pages +- ✅ Shared components and layouts across tenants +- ✅ Redis for tenant data storage +- ✅ Admin interface for managing tenants +- ✅ Emoji support for tenant branding +- ✅ Support for local development with subdomains +- ✅ Compatible with Vercel preview deployments -[Read the guide](https://vercel.com/guides/nextjs-multi-tenant-application) to learn how to deploy your own version of this template. +## Tech Stack -## Introduction +- [Next.js 15](https://nextjs.org/) with App Router +- [React 19](https://react.dev/) +- [Upstash Redis](https://upstash.com/) for data storage +- [Tailwind 4](https://tailwindcss.com/) for styling +- [shadcn/ui](https://ui.shadcn.com/) for the design system -Multi-tenant applications serve multiple customers across different subdomains/custom domains with a single unified codebase. +## Getting Started -For example, our demo is a multi-tenant application: +### Prerequisites -- Subdomain: [demo.vercel.pub](http://demo.vercel.pub) -- Custom domain: [platformize.co](http://platformize.co) (maps to [demo.vercel.pub](http://demo.vercel.pub)) -- Build your own: [app.vercel.pub](http://app.vercel.pub) +- Node.js 18.17.0 or later +- pnpm (recommended) or npm/yarn +- Upstash Redis account (for production) -Another example is [Hashnode](https://vercel.com/customers/hashnode), a popular blogging platform. Each writer has their own unique `.hashnode.dev` subdomain for their blog: +### Installation -- [eda.hashnode.dev](https://eda.hashnode.dev/) -- [katycodesstuff.hashnode.dev](https://katycodesstuff.hashnode.dev/) -- [akoskm.hashnode.dev](https://akoskm.hashnode.dev/) +1. Clone the repository: -Users can also map custom domains to their `.hashnode.dev` subdomain: + ```bash + git clone https://github.com/vercel/platforms.git + cd platforms + ``` -- [akoskm.com](https://akoskm.com/) → [akoskm.hashnode.dev](https://akoskm.hashnode.dev/) +2. Install dependencies: -This repository makes it easier than ever for creators to build their own platform. + ```bash + pnpm install + ``` -## Template features +3. Set up environment variables: + Create a `.env.local` file in the root directory with: -Forget manually setting up CNAME records, wrestling with DNS, or making custom server rewrite rules with NGINX. With Vercel and the Platforms Starter Kit, you can focus on building the next big thing. + ``` + KV_REST_API_URL=your_redis_url + KV_REST_API_TOKEN=your_redis_token + ``` -- **Custom domains**: Subdomain and custom domains support with [Edge Functions](https://vercel.com/features/edge-functions) and the [Vercel Domains API](https://domains-api.vercel.app/). -- **Static generation with ISR**: Performance without sacrificing personalization, by combining [Incremental Static Regeneration](https://vercel.com/docs/concepts/next.js/incremental-static-regeneration) (ISR) and [Middleware](https://vercel.com/docs/concepts/functions/edge-functions#middleware). ISR allows you to create new content (with custom domains) on demand without needing to redeploy your application. -- **Uploading custom images**: Allow your customers to upload custom thumbnail images with our Cloudinary integration. -- **Static tweets**: Avoid [Cumulative Layout Shift](https://vercel.com/blog/core-web-vitals) (CLS) from the native Twitter embed by using our [static tweets implementation](https://static-tweets-tailwind.vercel.app/) (supports image, video, gif, poll, retweets, quote retweets, and more). +4. Start the development server: -## Examples of platforms + ```bash + pnpm dev + ``` -Vercel customers like [Hashnode](https://vercel.com/customers/hashnode), [Super](https://super.so), and [Cal.com](https://cal.com) are building scalable platforms on top of Vercel and Next.js. There are multiple types of platforms you can build with this starter kit: +5. Access the application: + - Main site: http://localhost:3000 + - Admin panel: http://localhost:3000/admin + - Tenants: http://[tenant-name].localhost:3000 -### 1. Content creation platforms +## Multi-Tenant Architecture -These are content-heavy platforms (blogs) with simple, standardized page layouts and route structure. +This application demonstrates a subdomain-based multi-tenant architecture where: -> “With Vercel, we spend less time managing our infrastructure and more time delivering value to our users.” — Sandeep Panda, Co-founder, Hashnode +- Each tenant gets their own subdomain (`tenant.yourdomain.com`) +- The middleware handles routing requests to the correct tenant +- Tenant data is stored in Redis using a `subdomain:{name}` key pattern +- The main domain hosts the landing page and admin interface +- Subdomains are dynamically mapped to tenant-specific content -1. [Hashnode](https://hashnode.com) -2. [Mirror.xyz](https://mirror.xyz/) -3. [Read.cv](https://read.cv/) +The middleware (`middleware.ts`) intelligently detects subdomains across various environments (local development, production, and Vercel preview deployments). -### 2. Website & e-commerce store builders +## Deployment -No-code site builders with customizable pages. +This application is designed to be deployed on Vercel. To deploy: -By using Next.js and Vercel, [Super](https://super.so/) has fast, globally distributed websites with a no-code editor (Notion). Their customers get all the benefits of Next.js (like [Image Optimization](https://nextjs.org/docs/basic-features/image-optimization)) without touching any code. +1. Push your repository to GitHub +2. Connect your repository to Vercel +3. Configure environment variables +4. Deploy -1. [Super.so](https://super.so) -2. [Typedream](https://typedream.com) -3. [Makeswift](https://www.makeswift.com/) +For custom domains, make sure to: -### 3. B2B2C platforms - -Multi-tenant authentication, login, and access controls. - -With Vercel and Next.js, platforms like [Instatus](https://instatus.com) are able to create status pages that are *10x faster* than competitors. - -1. [Instatus](https://instatus.com/) -2. [Cal.com](https://cal.com/) -3. [Dub](https://dub.sh/) - -## Built on open source - -This working demo site was built using the Platforms Starter Kit and: - -- [Next.js](https://nextjs.org/) as the React framework -- [Tailwind](https://tailwindcss.com/) for CSS styling -- [Prisma](https://prisma.io/) as the ORM for database access -- [PlanetScale](https://planetscale.com/) as the database (MySQL) -- [NextAuth.js](https://next-auth.js.org/) for authentication -- [Vercel](http://vercel.com/) for deployment - -We also have another [example](https://github.com/vercel/examples/tree/main/solutions/platforms-slate-supabase) of the Platforms Starter Kit that uses Supabase for the database and Slate.js for the text editor. - -## Frequently Asked Questions - -- **Should we be generating static webpages with `getStaticProps` and `getStaticPaths` at build time? It doesn't seem to be very scalable.** - - For scale, we recommend using [Incremental Static Regeneration](https://nextjs.org/docs/basic-features/data-fetching/incremental-static-regeneration) instead. This basically means that instead of generating all pages at build time, you only specify a subset of pages and then generate the rest on the fly. Then when someone requests that page, all subsequent requests will be cached on the Vercel edge. You can also use [on-demand ISR](https://nextjs.org/docs/basic-features/data-fetching/incremental-static-regeneration#on-demand-revalidation) to programmatically invalidate caches per page every time someone makes a change to it, which is what we do [here](https://github.com/vercel/platforms/blob/1b2bd00055bbbdde8f2dcc89e0bdb2c3f8488f97/lib/api/post.ts#L243-L257). - -- **Is it wise to be using the `/_sites/[site]` path to serve all static pages/website? Wouldn't that lead to a significant amount of load on a single Next.js server?** - - The beauty about a serverless setup is you won’t have to worry about load since each request invokes a separate serverless function, and once it’s cached, you don’t invoke the server anymore (the page is served directly from the Vercel edge). Read more about the [Vercel Edge Network](https://vercel.com/docs/concepts/edge-network/overview) and [how caching works](https://vercel.com/docs/concepts/edge-network/caching). - - -## Caveats - -- This template does not work with i18n, which is an [advanced feature in Next.js](https://nextjs.org/docs/advanced-features/i18n-routing). - - -## Contributing - -- [Start a discussion](https://github.com/vercel/platforms/discussions) with a question, piece of feedback, or idea you want to share with the team. -- [Open an issue](https://github.com/vercel/platforms/issues) if you believe you've encountered a bug with the starter kit. - -## Author - -- Steven Tey ([@steventey](https://twitter.com/steventey)) - -## License - -The MIT License. - ---- - - - - +1. Add your root domain to Vercel +2. Set up a wildcard DNS record (`*.yourdomain.com`) on Vercel diff --git a/app/actions.ts b/app/actions.ts new file mode 100644 index 000000000..4e1f341ba --- /dev/null +++ b/app/actions.ts @@ -0,0 +1,69 @@ +'use server'; + +import { redis } from '@/lib/redis'; +import { isValidIcon } from '@/lib/subdomains'; +import { revalidatePath } from 'next/cache'; +import { redirect } from 'next/navigation'; +import { rootDomain, protocol } from '@/lib/utils'; + +export async function createSubdomainAction( + prevState: any, + formData: FormData +) { + const subdomain = formData.get('subdomain') as string; + const icon = formData.get('icon') as string; + + if (!subdomain || !icon) { + return { success: false, error: 'Subdomain and icon are required' }; + } + + if (!isValidIcon(icon)) { + return { + subdomain, + icon, + success: false, + error: 'Please enter a valid emoji (maximum 10 characters)' + }; + } + + const sanitizedSubdomain = subdomain.toLowerCase().replace(/[^a-z0-9-]/g, ''); + + if (sanitizedSubdomain !== subdomain) { + return { + subdomain, + icon, + success: false, + error: + 'Subdomain can only have lowercase letters, numbers, and hyphens. Please try again.' + }; + } + + const subdomainAlreadyExists = await redis.get( + `subdomain:${sanitizedSubdomain}` + ); + if (subdomainAlreadyExists) { + return { + subdomain, + icon, + success: false, + error: 'This subdomain is already taken' + }; + } + + await redis.set(`subdomain:${sanitizedSubdomain}`, { + emoji: icon, + createdAt: Date.now() + }); + + redirect(`${protocol}://${sanitizedSubdomain}.${rootDomain}`); +} + +export async function deleteSubdomainAction( + prevState: any, + formData: FormData +) { + const subdomain = formData.get('subdomain'); + await redis.del(`subdomain:${subdomain}`); + revalidatePath('/admin'); + return { success: 'Domain deleted successfully' }; +} diff --git a/app/admin/dashboard.tsx b/app/admin/dashboard.tsx new file mode 100644 index 000000000..047b49f97 --- /dev/null +++ b/app/admin/dashboard.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { useActionState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Trash2, Loader2 } from 'lucide-react'; +import Link from 'next/link'; +import { deleteSubdomainAction } from '@/app/actions'; +import { rootDomain, protocol } from '@/lib/utils'; + +type Tenant = { + subdomain: string; + emoji: string; + createdAt: number; +}; + +type DeleteState = { + error?: string; + success?: string; +}; + +function DashboardHeader() { + // TODO: You can add authentication here with your preferred auth provider + + return ( +
+

Subdomain Management

+
+ + {rootDomain} + +
+
+ ); +} + +function TenantGrid({ + tenants, + action, + isPending +}: { + tenants: Tenant[]; + action: (formData: FormData) => void; + isPending: boolean; +}) { + if (tenants.length === 0) { + return ( + + +

No subdomains have been created yet.

+
+
+ ); + } + + return ( +
+ {tenants.map((tenant) => ( + + +
+ {tenant.subdomain} +
+ + +
+
+
+ +
+
{tenant.emoji}
+
+ Created: {new Date(tenant.createdAt).toLocaleDateString()} +
+
+ +
+
+ ))} +
+ ); +} + +export function AdminDashboard({ tenants }: { tenants: Tenant[] }) { + const [state, action, isPending] = useActionState( + deleteSubdomainAction, + {} + ); + + return ( +
+ + + + {state.error && ( +
+ {state.error} +
+ )} + + {state.success && ( +
+ {state.success} +
+ )} +
+ ); +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 000000000..e02f75806 --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,20 @@ +import { getAllSubdomains } from '@/lib/subdomains'; +import type { Metadata } from 'next'; +import { AdminDashboard } from './dashboard'; +import { rootDomain } from '@/lib/utils'; + +export const metadata: Metadata = { + title: `Admin Dashboard | ${rootDomain}`, + description: `Manage subdomains for ${rootDomain}` +}; + +export default async function AdminPage() { + // TODO: You can add authentication here with your preferred auth provider + const tenants = await getAllSubdomains(); + + return ( +
+ +
+ ); +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 000000000..718d6fea4 Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 000000000..49797b770 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,199 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +: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); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} + +.emoji-picker-container .epr-body::-webkit-scrollbar { + width: 8px; +} + +.emoji-picker-container .epr-body::-webkit-scrollbar-track { + background: hsl(var(--background)); +} + +.emoji-picker-container .epr-body::-webkit-scrollbar-thumb { + background-color: hsl(var(--muted)); + border-radius: 20px; +} + +.emoji-picker-container .epr-body::-webkit-scrollbar-thumb:hover { + background-color: hsl(var(--muted-foreground) / 0.5); +} + +.emoji-picker-container .epr-category-nav { + padding: 8px 0; +} + +.emoji-picker-container .epr-header { + border-bottom: 1px solid hsl(var(--border)); +} + +.emoji-picker-container .epr-emoji-category-label { + background-color: hsl(var(--background)); + font-size: 0.875rem; + color: hsl(var(--muted-foreground)); + padding: 4px 8px; +} + +.emoji-picker-container .epr-search { + margin: 8px; + border-radius: var(--radius); + border: 1px solid hsl(var(--input)); + background-color: hsl(var(--background)); +} + +.emoji-picker-container .epr-search input { + border-radius: var(--radius); + background-color: transparent; + color: hsl(var(--foreground)); +} + +.emoji-picker-container .epr-emoji-category-content { + padding: 4px; +} + +.emoji-picker-container .epr-body { + padding: 0; +} + +.emoji-picker-container .epr-skin-tones { + border-radius: var(--radius); +} + +.emoji-picker-container button.epr-emoji { + border-radius: var(--radius); +} + +.emoji-picker-container button.epr-emoji:hover { + background-color: hsl(var(--accent)); +} + +.emoji-picker-container .epr-category-nav button { + opacity: 0.5; +} + +.emoji-picker-container .epr-category-nav button.active { + opacity: 1; +} + +.emoji-picker-container .epr-category-nav button:hover { + opacity: 0.8; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 000000000..0aa9de8d8 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,29 @@ +import type { Metadata } from 'next'; +import { Geist } from 'next/font/google'; +import { SpeedInsights } from '@vercel/speed-insights/next'; +import './globals.css'; + +const geistSans = Geist({ + variable: '--font-geist-sans', + subsets: ['latin'] +}); + +export const metadata: Metadata = { + title: 'Platforms Starter Kit', + description: 'Next.js template for building a multi-tenant SaaS.' +}; + +export default function RootLayout({ + children +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + + ); +} diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 000000000..3bdb1ba05 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,56 @@ +'use client'; + +import Link from 'next/link'; +import { useEffect, useState } from 'react'; +import { usePathname } from 'next/navigation'; +import { rootDomain, protocol } from '@/lib/utils'; + +export default function NotFound() { + const [subdomain, setSubdomain] = useState(null); + const pathname = usePathname(); + + useEffect(() => { + // Extract subdomain from URL if we're on a subdomain page + if (pathname?.startsWith('/subdomain/')) { + const extractedSubdomain = pathname.split('/')[2]; + if (extractedSubdomain) { + setSubdomain(extractedSubdomain); + } + } else { + // Try to extract from hostname for direct subdomain access + const hostname = window.location.hostname; + if (hostname.includes(`.${rootDomain.split(':')[0]}`)) { + const extractedSubdomain = hostname.split('.')[0]; + setSubdomain(extractedSubdomain); + } + } + }, [pathname]); + + return ( +
+
+

+ {subdomain ? ( + <> + {subdomain}.{rootDomain}{' '} + doesn't exist + + ) : ( + 'Subdomain Not Found' + )} +

+

+ This subdomain hasn't been created yet. +

+
+ + {subdomain ? `Create ${subdomain}` : `Go to ${rootDomain}`} + +
+
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 000000000..47ce6bf8a --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,33 @@ +import Link from 'next/link'; +import { SubdomainForm } from './subdomain-form'; +import { rootDomain } from '@/lib/utils'; + +export default async function HomePage() { + return ( +
+
+ + Admin + +
+ +
+
+

+ {rootDomain} +

+

+ Create your own subdomain with a custom emoji +

+
+ +
+ +
+
+
+ ); +} diff --git a/app/s/[subdomain]/page.tsx b/app/s/[subdomain]/page.tsx new file mode 100644 index 000000000..be83a2940 --- /dev/null +++ b/app/s/[subdomain]/page.tsx @@ -0,0 +1,63 @@ +import Link from 'next/link'; +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import { getSubdomainData } from '@/lib/subdomains'; +import { protocol, rootDomain } from '@/lib/utils'; + +export async function generateMetadata({ + params +}: { + params: Promise<{ subdomain: string }>; +}): Promise { + const { subdomain } = await params; + const subdomainData = await getSubdomainData(subdomain); + + if (!subdomainData) { + return { + title: rootDomain + }; + } + + return { + title: `${subdomain}.${rootDomain}`, + description: `Subdomain page for ${subdomain}.${rootDomain}` + }; +} + +export default async function SubdomainPage({ + params +}: { + params: Promise<{ subdomain: string }>; +}) { + const { subdomain } = await params; + const subdomainData = await getSubdomainData(subdomain); + + if (!subdomainData) { + notFound(); + } + + return ( +
+
+ + {rootDomain} + +
+ +
+
+
{subdomainData.emoji}
+

+ Welcome to {subdomain}.{rootDomain} +

+

+ This is your custom subdomain page +

+
+
+
+ ); +} diff --git a/app/subdomain-form.tsx b/app/subdomain-form.tsx new file mode 100644 index 000000000..e26371ab1 --- /dev/null +++ b/app/subdomain-form.tsx @@ -0,0 +1,150 @@ +'use client'; + +import type React from 'react'; + +import { useState } from 'react'; +import { useActionState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Popover, + PopoverContent, + PopoverTrigger +} from '@/components/ui/popover'; +import { Smile } from 'lucide-react'; +import { Card } from '@/components/ui/card'; +import { + EmojiPicker, + EmojiPickerContent, + EmojiPickerSearch, + EmojiPickerFooter +} from '@/components/ui/emoji-picker'; +import { createSubdomainAction } from '@/app/actions'; +import { rootDomain } from '@/lib/utils'; + +type CreateState = { + error?: string; + success?: boolean; + subdomain?: string; + icon?: string; +}; + +function SubdomainInput({ defaultValue }: { defaultValue?: string }) { + return ( +
+ +
+
+ +
+ + .{rootDomain} + +
+
+ ); +} + +function IconPicker({ + icon, + setIcon, + defaultValue +}: { + icon: string; + setIcon: (icon: string) => void; + defaultValue?: string; +}) { + const [isPickerOpen, setIsPickerOpen] = useState(false); + + const handleEmojiSelect = ({ emoji }: { emoji: string }) => { + setIcon(emoji); + setIsPickerOpen(false); + }; + + return ( +
+ +
+ +
+ +
+ {icon ? ( + {icon} + ) : ( + + No icon selected + + )} +
+ + + + + + + + + + + + +
+
+

+ Select an emoji to represent your subdomain +

+
+
+ ); +} + +export function SubdomainForm() { + const [icon, setIcon] = useState(''); + + const [state, action, isPending] = useActionState( + createSubdomainAction, + {} + ); + + return ( +
+ + + + + {state?.error && ( +
{state.error}
+ )} + + + + ); +} diff --git a/components.json b/components.json new file mode 100644 index 000000000..5a3c7506d --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/components/BlogCard.tsx b/components/BlogCard.tsx deleted file mode 100644 index 2c85bedc5..000000000 --- a/components/BlogCard.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import Link from "next/link"; -import BlurImage from "./BlurImage"; - -import type { Post } from "@prisma/client"; -import { placeholderBlurhash, toDateString } from "@/lib/utils"; - -interface BlogCardProps { - data: Pick< - Post, - "slug" | "image" | "imageBlurhash" | "title" | "description" | "createdAt" - >; -} - -export default function BlogCard({ data }: BlogCardProps) { - return ( - -
- {data.image ? ( - - ) : ( -
- ? -
- )} -
-

{data.title}

-

- {data.description} -

-

- Published {toDateString(data.createdAt)} -

-
-
- - ); -} diff --git a/components/BlurImage.tsx b/components/BlurImage.tsx deleted file mode 100644 index 8f606abfb..000000000 --- a/components/BlurImage.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import cn from "clsx"; -import Image from "next/image"; -import { useState } from "react"; - -import type { ComponentProps } from "react"; -import type { WithClassName } from "@/types"; - -interface BlurImageProps extends WithClassName, ComponentProps { - alt: string; -} - -export default function BlurImage(props: BlurImageProps) { - const [isLoading, setLoading] = useState(true); - - return ( - {props.alt} setLoading(false)} - /> - ); -} diff --git a/components/Cloudinary.tsx b/components/Cloudinary.tsx deleted file mode 100644 index f472b4488..000000000 --- a/components/Cloudinary.tsx +++ /dev/null @@ -1,61 +0,0 @@ -/* eslint-disable */ - -import Head from "next/head"; - -import type { MouseEvent, ReactNode } from "react"; -import type { - CloudinaryCallbackImage, - CloudinaryWidget, - CloudinaryWidgetResult, -} from "@/types"; - -interface ChildrenProps { - open: (e: MouseEvent) => void; -} - -interface CloudinaryUploadWidgetProps { - callback: (image: CloudinaryCallbackImage) => void; - children: (props: ChildrenProps) => ReactNode; -} - -export default function CloudinaryUploadWidget({ - callback, - children, -}: CloudinaryUploadWidgetProps) { - function showWidget() { - const widget: CloudinaryWidget = window.cloudinary.createUploadWidget( - { - cloudName: "vercel-platforms", - uploadPreset: "w0vnflc6", - cropping: true, - }, - (error: unknown | undefined, result: CloudinaryWidgetResult) => { - if (!error && result && result.event === "success") { - callback(result.info); - } - } - ); - - widget.open(); - } - - function open(e: MouseEvent) { - e.preventDefault(); - showWidget(); - } - - return ( - <> - - // this is Next.js specific, but if you're using something like Create - // React App, you could download the script in componentDidMount using - // this method: https://stackoverflow.com/a/34425083/1424568 -