diff --git a/.aiignore b/.aiignore new file mode 100644 index 0000000..7d17f6f --- /dev/null +++ b/.aiignore @@ -0,0 +1,27 @@ +# An .aiignore file follows the same syntax as a .gitignore file. +# .gitignore documentation: https://git-scm.com/docs/gitignore + +# you can ignore files +.DS_Store +*.log +*.tmp + +# or folders +dist/ +build/ +out/ + +.cursor +.cursorignore +database.types.ts +playwright-report +test-results +web/supabase/migrations +pnpm-lock.yaml +.env.local +.env.production.local +.idea +.vscode +.zed +tsconfig.tsbuildinfo +.windsurfrules \ No newline at end of file diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 0000000..8ce3491 --- /dev/null +++ b/.cursorignore @@ -0,0 +1,12 @@ +database.types.ts +playwright-report +test-results +web/supabase/migrations +pnpm-lock.yaml +.env.local +.env.production.local +.idea +.vscode +.zed +tsconfig.tsbuildinfo +.windsurfrules \ No newline at end of file diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..82a3969 --- /dev/null +++ b/.env.development @@ -0,0 +1,27 @@ +# This file is used to define environment variables for the development environment. +# These values are only used when running the app in development mode. + +# SUPABASE +NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 +NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 +SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU + +## THIS IS FOR DEVELOPMENT ONLY - DO NOT USE IN PRODUCTION +SUPABASE_DB_WEBHOOK_SECRET=WEBHOOKSECRET + +# EMAILS +EMAIL_SENDER="Makerkit " +EMAIL_PORT=54325 +EMAIL_HOST=localhost +EMAIL_TLS=false +EMAIL_USER=user +EMAIL_PASSWORD=password + +# CONTACT FORM +CONTACT_EMAIL=test@makerkit.dev + +# STRIPE +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= + +# MAILER +MAILER_PROVIDER=nodemailer \ No newline at end of file diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..417308e --- /dev/null +++ b/.env.production @@ -0,0 +1,12 @@ +# PRODUCTION ENVIRONMENT VARIABLES + +## DO NOT ADD VARS HERE UNLESS THEY ARE PUBLIC OR NOT SENSITIVE +## THIS ENV IS USED FOR PRODUCTION AND IS COMMITED TO THE REPO +## AVOID PLACING SENSITIVE DATA IN THIS FILE. +## PUBLIC KEYS OR CONFIGURATION ARE OKAY TO BE PLACED HERE. + +# SUPABASE +NEXT_PUBLIC_SUPABASE_URL= + +# STRIPE +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..3d047a0 --- /dev/null +++ b/.env.test @@ -0,0 +1,22 @@ +# TEST ENVIRONMENT VARIABLES +NEXT_PUBLIC_CI=true + +# SUPABASE +NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 +NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 +SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU + +## THIS IS FOR DEVELOPMENT ONLY - DO NOT USE IN PRODUCTION +SUPABASE_DB_WEBHOOK_SECRET=WEBHOOKSECRET + +EMAIL_SENDER=test@makerkit.dev +EMAIL_PORT=54325 +EMAIL_HOST=localhost +EMAIL_TLS=false +EMAIL_USER=user +EMAIL_PASSWORD=password + +# STRIPE +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51K9cWKI1i3VnbZTq2HGstY2S8wt3peF1MOqPXFO4LR8ln2QgS7GxL8XyKaKLvn7iFHeqAnvdDw0o48qN7rrwwcHU00jOtKhjsf + +CONTACT_EMAIL=test@makerkit.dev \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..dad7c34 --- /dev/null +++ b/.npmrc @@ -0,0 +1,10 @@ +peer-legacy-deps=true +dedupe-peer-dependents=true +use-lockfile-v6=true +resolution-mode=highest +package-manager-strict=true +public-hoist-pattern[]=*i18next* +public-hoist-pattern[]=*eslint* +public-hoist-pattern[]=*prettier* +public-hoist-pattern[]=*require-in-the-middle* +public-hoist-pattern[]=*import-in-the-middle* \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..89e0c3d --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20.10 \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..dca02d0 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +database.types.ts +playwright-report +*.hbs \ No newline at end of file diff --git a/.windsurfrules b/.windsurfrules new file mode 100644 index 0000000..df3e6cf --- /dev/null +++ b/.windsurfrules @@ -0,0 +1,225 @@ +# Makerkit Guidelines + +## Project Stack +- Framework: Next.js 15 App Router, TypeScript, React, Node.js +- Backend: Supabase with Postgres +- UI: Shadcn UI, Tailwind CSS +- Key libraries: React Hook Form, React Query, Zod, Lucide React +- Focus: Code clarity, Readability, Best practices, Maintainability + +## Project Structure +``` +/apps/web/ + /app + /home # protected routes + /(user) # user workspace + /[account] # team workspace + /(marketing) # marketing pages + /auth # auth pages + /components # global components + /config # global config + /lib # global utils + /content # markdoc content + /supabase # supabase root +``` + +## Core Principles + +### Data Flow +1. Server Components + - Use Supabase Client directly via `getSupabaseServerClient` + - Handle errors with proper boundaries + - Example: + ```tsx + async function ServerComponent() { + const client = getSupabaseServerClient(); + const { data, error } = await client.from('notes').select('*'); + if (error) return ; + return ; + } + ``` + +2. Client Components + - Use React Query for data fetching + - Implement proper loading states + - Example: + ```tsx + function useNotes() { + const { data, isLoading } = useQuery({ + queryKey: ['notes'], + queryFn: async () => { + const { data } = await fetch('/api/notes'); + return data; + } + }); + return { data, isLoading }; + } + ``` + +### Server Actions +- Name files as "server-actions.ts" in `_lib/server` folder +- Export with "Action" suffix +- Use `enhanceAction` with proper typing +- Example: + ```tsx + export const createNoteAction = enhanceAction( + async function (data, user) { + const client = getSupabaseServerClient(); + const { error } = await client + .from('notes') + .insert({ ...data, user_id: user.id }); + if (error) throw error; + return { success: true }; + }, + { + auth: true, + schema: NoteSchema, + } + ); + ``` + +### Route Handlers + +- Use `enhanceRouteHandler` to wrap route handlers +- Use Route Handlers when data fetching from Client Components + +## Database & Security + +### RLS Policies +- Strive to create a safe, robust, secure and consistent database schema +- Always consider the compromises you need to make and explain them so I can make an educated decision. Follow up with the considerations make and explain them. +- Enable RLS by default and propose the required RLS policies +- `public.accounts` are the root tables for the application +- Implement cascading deletes when appropriate +- Ensure strong consistency considering triggers and constraints +- Always use Postgres schemas explicitly (e.g., `public.accounts`) + +## Forms Pattern + +### 1. Schema Definition +```tsx +// schema/note.schema.ts +import { z } from 'zod'; + +export const NoteSchema = z.object({ + title: z.string().min(1).max(100), + content: z.string().min(1), + category: z.enum(['work', 'personal']), +}); +``` + +### 2. Form Component +```tsx +'use client'; + +export function NoteForm() { + const [pending, startTransition] = useTransition(); + const form = useForm({ + resolver: zodResolver(NoteSchema), + defaultValues: { title: '', content: '', category: 'personal' } + }); + + const onSubmit = (data: z.infer) => { + startTransition(async () => { + try { + await createNoteAction(data); + form.reset(); + } catch (error) { + // Handle error + } + }); + }; + + return ( +
+ ( + + Title + + + + + + )} /> + {/* Other fields */} + + ); +} +``` + +## Error Handling + +- Consider logging asynchronous requests in server code using the `@kit/shared/logger` +- Handle promises and async/await gracefully +- Consider the unhappy path and handle errors appropriately + +### Structured Logging +```tsx +const ctx = { + name: 'create-note', + userId: user.id, + noteId: note.id +}; + +logger.info(ctx, 'Creating new note...'); + +try { + await createNote(); + logger.info(ctx, 'Note created successfully'); +} catch (error) { + logger.error(ctx, 'Failed to create note', { error }); + throw error; +} +``` + +## Context Management + +In client components, we can use the `useUserWorkspace` hook to access the user's workspace data. + +### Personal Account +```tsx +'use client'; + +function PersonalDashboard() { + const { workspace, user } = useUserWorkspace(); + if (!workspace) return null; + + return ( +
+

Welcome, {user.email}

+ +
+ ); +} +``` + +### Team Account +In client components, we can use the `useTeamAccountWorkspace` hook to access the team account's workspace data. It only works under the `/home/[account]` route. + +```tsx +'use client'; + +function TeamDashboard() { + const { account, user } = useTeamAccountWorkspace(); + + return ( +
+

{account.name}

+ + +
+ ); +} +``` + +## UI Components + +- Reusable UI components are defined in the "packages/ui" package named "@kit/ui". +- By exporting the component from the "exports" field, we can import it using the "@kit/ui/{component-name}" format. + +## Creating Pages + +When creating new pages ensure: +- The page is exported using `withI18n(Page)` to enable i18n. +- The page has the required and correct metadata using the `metadata` or `generateMetadata` function. +- Don't worry about authentication, it's handled in the middleware. diff --git a/app/(auth-pages)/forgot-password/page.tsx b/app/(auth-pages)/forgot-password/page.tsx deleted file mode 100644 index bcf9725..0000000 --- a/app/(auth-pages)/forgot-password/page.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { forgotPasswordAction } from "@/app/actions"; -import { FormMessage, Message } from "@/components/form-message"; -import { SubmitButton } from "@/components/submit-button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import Link from "next/link"; -import { SmtpMessage } from "../smtp-message"; - -export default async function ForgotPassword(props: { - searchParams: Promise; -}) { - const searchParams = await props.searchParams; - return ( - <> -
-
-

Reset Password

-

- Already have an account?{" "} - - Sign in - -

-
-
- - - - Reset Password - - -
-
- - - ); -} diff --git a/app/(auth-pages)/layout.tsx b/app/(auth-pages)/layout.tsx deleted file mode 100644 index e038de1..0000000 --- a/app/(auth-pages)/layout.tsx +++ /dev/null @@ -1,9 +0,0 @@ -export default async function Layout({ - children, -}: { - children: React.ReactNode; -}) { - return ( -
{children}
- ); -} diff --git a/app/(auth-pages)/sign-in/page.tsx b/app/(auth-pages)/sign-in/page.tsx deleted file mode 100644 index 7628cc7..0000000 --- a/app/(auth-pages)/sign-in/page.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { signInAction } from "@/app/actions"; -import { FormMessage, Message } from "@/components/form-message"; -import { SubmitButton } from "@/components/submit-button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import Link from "next/link"; - -export default async function Login(props: { searchParams: Promise }) { - const searchParams = await props.searchParams; - return ( -
-

Sign in

-

- Don't have an account?{" "} - - Sign up - -

-
- - -
- - - Forgot Password? - -
- - - Sign in - - -
-
- ); -} diff --git a/app/(auth-pages)/sign-up/page.tsx b/app/(auth-pages)/sign-up/page.tsx deleted file mode 100644 index 31b5a6d..0000000 --- a/app/(auth-pages)/sign-up/page.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { signUpAction } from "@/app/actions"; -import { FormMessage, Message } from "@/components/form-message"; -import { SubmitButton } from "@/components/submit-button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import Link from "next/link"; -import { SmtpMessage } from "../smtp-message"; - -export default async function Signup(props: { - searchParams: Promise; -}) { - const searchParams = await props.searchParams; - if ("message" in searchParams) { - return ( -
- -
- ); - } - - return ( - <> -
-

Sign up

-

- Already have an account?{" "} - - Sign in - -

-
- - - - - - Sign up - - -
-
- - - ); -} diff --git a/app/(auth-pages)/smtp-message.tsx b/app/(auth-pages)/smtp-message.tsx deleted file mode 100644 index 84c21fc..0000000 --- a/app/(auth-pages)/smtp-message.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { ArrowUpRight, InfoIcon } from "lucide-react"; -import Link from "next/link"; - -export function SmtpMessage() { - return ( -
- -
- - Note: Emails are rate limited. Enable Custom SMTP to - increase the rate limit. - -
- - Learn more - -
-
-
- ); -} diff --git a/app/(marketing)/(legal)/cookie-policy/page.tsx b/app/(marketing)/(legal)/cookie-policy/page.tsx new file mode 100644 index 0000000..d3c5eee --- /dev/null +++ b/app/(marketing)/(legal)/cookie-policy/page.tsx @@ -0,0 +1,30 @@ +import { SitePageHeader } from '~/(marketing)/_components/site-page-header'; +import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; +import { withI18n } from '~/lib/i18n/with-i18n'; + +export async function generateMetadata() { + const { t } = await createI18nServerInstance(); + + return { + title: t('marketing:cookiePolicy'), + }; +} + +async function CookiePolicyPage() { + const { t } = await createI18nServerInstance(); + + return ( +
+ + +
+
Your terms of service content here
+
+
+ ); +} + +export default withI18n(CookiePolicyPage); diff --git a/app/(marketing)/(legal)/privacy-policy/page.tsx b/app/(marketing)/(legal)/privacy-policy/page.tsx new file mode 100644 index 0000000..b8ff856 --- /dev/null +++ b/app/(marketing)/(legal)/privacy-policy/page.tsx @@ -0,0 +1,30 @@ +import { SitePageHeader } from '~/(marketing)/_components/site-page-header'; +import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; +import { withI18n } from '~/lib/i18n/with-i18n'; + +export async function generateMetadata() { + const { t } = await createI18nServerInstance(); + + return { + title: t('marketing:privacyPolicy'), + }; +} + +async function PrivacyPolicyPage() { + const { t } = await createI18nServerInstance(); + + return ( +
+ + +
+
Your terms of service content here
+
+
+ ); +} + +export default withI18n(PrivacyPolicyPage); diff --git a/app/(marketing)/(legal)/terms-of-service/page.tsx b/app/(marketing)/(legal)/terms-of-service/page.tsx new file mode 100644 index 0000000..ee7d0cb --- /dev/null +++ b/app/(marketing)/(legal)/terms-of-service/page.tsx @@ -0,0 +1,30 @@ +import { SitePageHeader } from '~/(marketing)/_components/site-page-header'; +import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; +import { withI18n } from '~/lib/i18n/with-i18n'; + +export async function generateMetadata() { + const { t } = await createI18nServerInstance(); + + return { + title: t('marketing:termsOfService'), + }; +} + +async function TermsOfServicePage() { + const { t } = await createI18nServerInstance(); + + return ( +
+ + +
+
Your terms of service content here
+
+
+ ); +} + +export default withI18n(TermsOfServicePage); diff --git a/app/(marketing)/_components/site-footer.tsx b/app/(marketing)/_components/site-footer.tsx new file mode 100644 index 0000000..bd8fdb4 --- /dev/null +++ b/app/(marketing)/_components/site-footer.tsx @@ -0,0 +1,58 @@ +import { Footer } from '@kit/ui/marketing'; +import { Trans } from '@kit/ui/trans'; + +import { AppLogo } from '~/components/app-logo'; +import appConfig from '~/config/app.config'; + +export function SiteFooter() { + return ( +