├── .cursor └── mcp.json.example ├── .env.example ├── .eslintrc.json ├── .gitignore ├── LICENSE ├── README.md ├── app ├── api │ ├── stripe │ │ ├── cancel │ │ │ └── route.ts │ │ ├── reactivate │ │ │ └── route.ts │ │ ├── sync │ │ │ └── route.ts │ │ ├── test │ │ │ └── route.ts │ │ └── webhook │ │ │ └── route.ts │ └── user │ │ └── delete │ │ └── route.ts ├── auth │ └── callback │ │ └── route.ts ├── dashboard │ └── page.tsx ├── favicon-vercel.ico ├── favicon.ico ├── globals.css ├── layout.tsx ├── login │ └── page.tsx ├── metadata.ts ├── page.tsx ├── pay │ └── page.tsx ├── profile │ └── page.tsx ├── reset-password │ └── page.tsx ├── update-password │ └── page.tsx └── verify-email │ └── page.tsx ├── components ├── AccountManagement.tsx ├── BuyMeCoffee.tsx ├── DemoWidget.tsx ├── ForgotPasswordModal.tsx ├── LoadingSpinner.tsx ├── LoginForm.tsx ├── MetricCard.tsx ├── OnboardingTour.tsx ├── PostHogErrorBoundary.tsx ├── PostHogPageView.tsx ├── PricingSection.tsx ├── StripeBuyButton.tsx ├── SubscriptionStatus.tsx ├── TopBar.tsx ├── TypewriterEffect.tsx └── VideoModal.tsx ├── config └── api.ts ├── contexts ├── AuthContext.tsx ├── PostHogContext.tsx └── ProtectedRoute.tsx ├── eslint.config.mjs ├── hooks ├── useSubscription.ts └── useTrialStatus.ts ├── initial_supabase_table_schema.sql ├── next.config.js ├── next.config.ts ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public ├── Google-Logo.png ├── file.svg ├── globe.svg ├── next.svg ├── vercel.svg └── window.svg ├── settings.json ├── tailwind.config.ts ├── tsconfig.json ├── types ├── ValidateEntryTypes.ts └── stripe.d.ts └── utils ├── analytics.ts ├── cors.ts ├── env.ts ├── posthog.ts ├── supabase-admin.ts └── supabase.ts /.cursor/mcp.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "stripe": { 4 | "command": "npx", 5 | "args": [ 6 | "-y", 7 | "@stripe/mcp" 8 | ], 9 | "env": { 10 | "STRIPE_SECRET_KEY": "your_stripe_test_key_here" 11 | } 12 | }, 13 | "supabase": { 14 | "command": "npx", 15 | "args": [ 16 | "-y", 17 | "@supabase/mcp-server-supabase@latest", 18 | "--access-token", 19 | "your_supabase_access_token_here" 20 | ] 21 | }, 22 | "github": { 23 | "command": "docker", 24 | "args": [ 25 | "run", 26 | "-i", 27 | "--rm", 28 | "-e", 29 | "GITHUB_PERSONAL_ACCESS_TOKEN", 30 | "ghcr.io/github/github-mcp-server" 31 | ], 32 | "env": { 33 | "GITHUB_PERSONAL_ACCESS_TOKEN": "your_github_personal_access_token_here" 34 | } 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_APP_URL=http://localhost:8000 2 | NEXT_PUBLIC_API_URL=http://localhost:8080 3 | NEXT_PUBLIC_WS_URL=ws://localhost:8080 4 | 5 | # Supabase Configuration 6 | NEXT_PUBLIC_SUPABASE_URL= 7 | NEXT_PUBLIC_SUPABASE_ANON_KEY= 8 | SUPABASE_SERVICE_ROLE_KEY= 9 | 10 | # OpenAI Configuration (you'll need to add your key) 11 | OPENAI_API_KEY= 12 | 13 | # Stripe Configuration 14 | # NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_ 15 | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_ 16 | NEXT_PUBLIC_STRIPE_BUTTON_ID=buy_btn_ 17 | # STRIPE_SECRET_KEY=sk_test_ 18 | STRIPE_SECRET_KEY=sk_live_ 19 | # STRIPE_WEBHOOK_SECRET=whsec_ 20 | STRIPE_WEBHOOK_SECRET=whsec_ 21 | 22 | # ANALYTICS 23 | NEXT_PUBLIC_POSTHOG_KEY= 24 | NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals", 3 | "rules": { 4 | "react/no-unescaped-entities": "off", 5 | "@typescript-eslint/no-unused-vars": ["warn", { 6 | "varsIgnorePattern": "^_", 7 | "argsIgnorePattern": "^_" 8 | }], 9 | "@typescript-eslint/no-explicit-any": "warn", 10 | "react-hooks/rules-of-hooks": "warn", 11 | "react/jsx-no-comment-textnodes": "warn" 12 | }, 13 | "ignorePatterns": [ 14 | "node_modules/", 15 | ".next/", 16 | "public/" 17 | ] 18 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | .idea/ 27 | .vscode/ 28 | *.swp 29 | *.swo 30 | 31 | # debug 32 | npm-debug.log* 33 | yarn-debug.log* 34 | yarn-error.log* 35 | .pnpm-debug.log* 36 | 37 | # local env files 38 | .env 39 | .env*.local 40 | .env.development 41 | .env.test 42 | .env.production 43 | .env.local 44 | .env.development.local 45 | .env.test.local 46 | .env.production.local 47 | 48 | # vercel 49 | .vercel 50 | 51 | # typescript 52 | *.tsbuildinfo 53 | next-env.d.ts 54 | 55 | # Supabase 56 | **/supabase/.temp 57 | .supabase/ 58 | 59 | # Security and keys 60 | *.key 61 | *.pem 62 | *.cert 63 | *.crt 64 | *.p12 65 | *.pfx 66 | *.keystore 67 | 68 | # Logs 69 | logs 70 | *.log 71 | 72 | # Cache directories 73 | .cache/ 74 | .next/cache/ 75 | 76 | # Cursor 77 | .cursor/mcp.json 78 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 ShenSeanChen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next.js + Stripe + Supabase Production-Ready Template 2 | 3 | A production-ready Next.js template featuring authentication, dark mode support, Stripe integration, and a clean, modern UI. Built with TypeScript and Tailwind CSS. 4 | 5 | ![License](https://img.shields.io/badge/license-MIT-blue.svg) 6 | ![Next.js](https://img.shields.io/badge/Next.js-14-black) 7 | ![TypeScript](https://img.shields.io/badge/TypeScript-5.0-blue) 8 | ![Tailwind](https://img.shields.io/badge/Tailwind-3.0-38B2AC) 9 | 10 | 📹 Full YouTube Guide: [Youtube link](https://www.youtube.com/watch?v=ad1BxZufer8&list=PLE9hy4A7ZTmpGq7GHf5tgGFWh2277AeDR&index=8) 11 | 12 | 🚀 X Post: [X link](https://x.com/ShenSeanChen/status/1895163913161109792) 13 | 14 | 💡 Try the App: [App link](https://mvp.seanchen.io) 15 | 16 | ☕️ Buy me a coffee: [Cafe Latte](https://buy.stripe.com/5kA176bA895ggog4gh) 17 | 18 | ## ✨ Features 19 | 20 | - 🔐 Authentication with Supabase 21 | - 💳 Stripe payment integration 22 | - 🌓 Dark mode support 23 | - 📱 Responsive design 24 | - 🎨 Tailwind CSS styling 25 | - 🔄 Framer Motion animations 26 | - 🛡️ TypeScript support 27 | - 📊 Error boundary implementation 28 | - 🔍 SEO optimized 29 | - 🤖 MCP integration for AI-powered development 30 | 31 | ## 🚀 Getting Started 32 | 33 | ### Prerequisites 34 | 35 | - Node.js 18+ 36 | - npm or yarn 37 | - A Supabase account 38 | - A Stripe account 39 | - A Google Cloud Platform account 40 | 41 | ### Installation and Setup 42 | 43 | 1. Clone the template: 44 | 45 | ```bash 46 | git clone https://github.com/ShenSeanChen/launch-stripe-nextjs-supabase my-full-stack-app 47 | cd my-full-stack-app 48 | rm -rf .git 49 | git init 50 | git add . 51 | git commit -m "Initial commit" 52 | # git remote add origin https://github.com/ShenSeanChen/my-full-stack-app.git 53 | git remote add origin https://github.com/USE_YOUR_OWN_GITHUB_NAME/my-full-stack-app.git 54 | git push -u origin main 55 | ``` 56 | 57 | 2. Install dependencies: 58 | ```bash 59 | npm install 60 | ``` 61 | or 62 | ```bash 63 | yarn install 64 | ``` 65 | 66 | 3. Create .env.local with all variables from .env.example 67 | ``` 68 | NEXT_PUBLIC_APP_URL=http://localhost:8000 69 | NEXT_PUBLIC_API_URL=http://localhost:8080 70 | NEXT_PUBLIC_WS_URL=ws://localhost:8080 71 | 72 | # Supabase Configuration 73 | NEXT_PUBLIC_SUPABASE_URL= 74 | NEXT_PUBLIC_SUPABASE_ANON_KEY= 75 | SUPABASE_SERVICE_ROLE_KEY= 76 | 77 | # OpenAI Configuration (you'll need to add your key) 78 | OPENAI_API_KEY= 79 | 80 | # Stripe Configuration 81 | # NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_ 82 | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_ 83 | NEXT_PUBLIC_STRIPE_BUTTON_ID=buy_btn_ 84 | # STRIPE_SECRET_KEY=sk_test_ 85 | STRIPE_SECRET_KEY=sk_live_ 86 | # STRIPE_WEBHOOK_SECRET=whsec_ 87 | STRIPE_WEBHOOK_SECRET=whsec_ 88 | 89 | # ANALYTICS 90 | NEXT_PUBLIC_POSTHOG_KEY= 91 | NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com 92 | ``` 93 | 94 | 4. Set up Google Cloud Platform (GCP): 95 | - Create new OAuth 2.0 credentials in GCP Console 96 | - Configure authorized JavaScript origins 97 | - Configure redirect URIs 98 | - Save the Client ID and Client Secret 99 | 100 | 5. Configure Supabase: 101 | 102 | a. Get API Keys (Project Settings > API): 103 | - Project URL → NEXT_PUBLIC_SUPABASE_URL 104 | - Anon Public Key → NEXT_PUBLIC_SUPABASE_ANON_KEY 105 | - Service Role Secret → SUPABASE_SERVICE_ROLE_KEY 106 | 107 | b. Set up Authentication: 108 | - Go to Authentication > Providers > Google 109 | - Add your GCP Client ID and Client Secret 110 | - Update Site URL and Redirect URLs 111 | 112 | c. Database Setup: 113 | - Enable Row Level Security (RLS) for all tables 114 | - Create policies for authenticated users and service roles 115 | - Create the following trigger function: 116 | 117 | ```sql 118 | CREATE OR REPLACE FUNCTION public.handle_new_user() 119 | RETURNS trigger AS $$ 120 | BEGIN 121 | INSERT INTO public.users (id, email, created_at, updated_at, is_deleted) 122 | VALUES (NEW.id, NEW.email, NOW(), NOW(), FALSE); 123 | 124 | INSERT INTO public.user_preferences (user_id, has_completed_onboarding) 125 | VALUES (NEW.id, FALSE); 126 | 127 | INSERT INTO public.user_trials (user_id, trial_start_time, trial_end_time) 128 | VALUES (NEW.id, NOW(), NOW() + INTERVAL '48 hours'); 129 | 130 | RETURN NEW; 131 | END; 132 | $$ LANGUAGE plpgsql SECURITY DEFINER; 133 | 134 | CREATE TRIGGER on_auth_user_created 135 | AFTER INSERT ON auth.users 136 | FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); 137 | ``` 138 | 139 | 6. Set up Stripe: 140 | 141 | a. Create a live account and configure: 142 | - Create product in Product Catalog 143 | - Create promotional coupon codes 144 | - Set up Payment Link with trial period 145 | 146 | b. Get required keys: 147 | - Publishable Key → NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY 148 | - Secret Key → STRIPE_SECRET_KEY 149 | - Buy Button ID → NEXT_PUBLIC_STRIPE_BUTTON_ID 150 | 151 | c. Configure webhooks: 152 | - Add endpoint: your_url/api/stripe/webhook 153 | - Subscribe to events: customer.subscription.*, checkout.session.*, invoice.*, payment_intent.* 154 | - Copy Signing Secret → STRIPE_WEBHOOK_SECRET 155 | 156 | 8. Start the development server: 157 | ```bash 158 | npm run dev 159 | ``` 160 | or 161 | ```bash 162 | yarn dev 163 | ``` 164 | 165 | 8. Open [http://localhost:3000](http://localhost:3000) in your browser. 166 | 167 | ## 🛠️ MCP Integration Setup 168 | 169 | ### What is MCP? 170 | 171 | MCP (Model Control Protocol) enables enhanced AI assistant capabilities for this project, allowing the AI to interact directly with your Stripe and Supabase accounts to help with debugging, configuring, and managing your application. 172 | 173 | For a comprehensive demonstration of MCP capabilities, check out our dedicated demo repository: 174 | - 🔗 [launch-mcp-demo](https://github.com/ShenSeanChen/launch-mcp-demo) - Collection of powerful MCP tools 175 | - 📹 [Full YouTube Guide](https://www.youtube.com/watch?v=sfCBCyNyw7U&list=PLE9hy4A7ZTmpGq7GHf5tgGFWh2277AeDR&index=10) 176 | - 🚀 [X Post](https://x.com/ShenSeanChen/status/1910057838032097688) 177 | 178 | ### Setting up MCP 179 | 180 | 1. Create an `mcp.json` file: 181 | 182 | Copy the example file to create your own configuration: 183 | 184 | ```bash 185 | cp .cursor/mcp.json.example .cursor/mcp.json 186 | ``` 187 | 188 | 2. Configure your credentials: 189 | 190 | a. Stripe Integration: 191 | - Get your Stripe API key from the Stripe Dashboard 192 | - Replace `your_stripe_test_key_here` with your actual test key 193 | 194 | b. Supabase Integration: 195 | - Generate a Supabase access token from your Supabase dashboard (Project Settings > API) 196 | - Replace `your_supabase_access_token_here` with your actual token 197 | 198 | c. GitHub Integration (optional): 199 | - Create a GitHub Personal Access Token with appropriate permissions 200 | - Replace `your_github_personal_access_token_here` with your actual token 201 | 202 | 3. Example of a completed `mcp.json` file: 203 | 204 | ```json 205 | { 206 | "mcpServers": { 207 | "stripe": { 208 | "command": "npx", 209 | "args": [ 210 | "-y", 211 | "@stripe/mcp" 212 | ], 213 | "env": { 214 | "STRIPE_SECRET_KEY": "sk_test_51ABC123..." 215 | } 216 | }, 217 | "supabase": { 218 | "command": "npx", 219 | "args": [ 220 | "-y", 221 | "@supabase/mcp-server-supabase@latest", 222 | "--access-token", 223 | "sbp_1234abcd5678efgh..." 224 | ] 225 | } 226 | } 227 | } 228 | ``` 229 | 230 | 4. Using MCP with AI assistants: 231 | 232 | After configuring `mcp.json`, the AI assistant within the Cursor editor will be able to: 233 | - Query and manage your Stripe subscriptions 234 | - Interact with your Supabase database 235 | - Help troubleshoot integration issues 236 | - Provide contextual help based on your actual configuration 237 | 238 | 5. Security Considerations: 239 | 240 | - Never commit your `mcp.json` file to version control 241 | - Use test credentials during development 242 | - Limit access tokens to only the permissions needed 243 | 244 | ### Extending MCP with Additional Tools 245 | 246 | The MCP framework can be extended with various tools beyond Stripe and Supabase. Our [launch-mcp-demo](https://github.com/ShenSeanChen/launch-mcp-demo) repository demonstrates how to integrate basic MCP examples. 247 | 248 | To integrate these additional tools with your project: 249 | 250 | 1. Clone the demo repository: 251 | ```bash 252 | git clone https://github.com/ShenSeanChen/launch-mcp-demo.git 253 | ``` 254 | 255 | 2. Follow the installation instructions in the repository's README 256 | 257 | 3. Update your `.cursor/mcp.json` to include the additional tools: 258 | ```json 259 | { 260 | "mcpServers": { 261 | "stripe": { 262 | // Your existing Stripe configuration 263 | }, 264 | "supabase": { 265 | // Your existing Supabase configuration 266 | }, 267 | "weather": { 268 | "command": "/path/to/your/python/environment", 269 | "args": [ 270 | "--directory", 271 | "/path/to/launch-mcp-demo/weather", 272 | "run", 273 | "weather.py" 274 | ] 275 | }, 276 | "files": { 277 | "command": "/path/to/your/python/environment", 278 | "args": [ 279 | "--directory", 280 | "/path/to/launch-mcp-demo/files", 281 | "run", 282 | "files.py" 283 | ] 284 | } 285 | } 286 | } 287 | ``` 288 | 289 | 4. Restart your Cursor editor to apply the changes 290 | 291 | These additional tools can help enhance your development workflow and provide more capabilities to the AI assistant when working with your project. 292 | 293 | ## 📖 Project Structure 294 | 295 | ``` 296 | ├── app/ # Next.js 14 app directory 297 | │ ├── api/ # API routes 298 | │ │ ├── stripe/ # Stripe payment endpoints 299 | │ │ └── user/ # User API endpoints 300 | │ ├── auth/ # Auth-related pages 301 | │ │ ├── callback/ # handle auth callback 302 | │ ├── dashboard/ # Dashboard pages 303 | │ ├── pay/ # Payment pages 304 | │ ├── profile/ # User profile pages 305 | │ ├── reset-password/ # Reset password pages 306 | │ ├── update-password/ # Update password pages 307 | │ ├── verify-email/ # Verify email pages 308 | │ ├── layout.tsx # Root layout 309 | │ └── page.tsx # Home page 310 | ├── components/ # Reusable components 311 | ├── contexts/ # React contexts 312 | ├── hooks/ # Custom React hooks 313 | ├── utils/ # Utility functions 314 | ├── types/ # TypeScript type definitions 315 | ├── public/ # Static assets 316 | ├── styles/ # Global styles 317 | └── .cursor/ # Cursor editor and MCP configurations 318 | ├── mcp.json.example # Example MCP configuration 319 | └── mcp.json # Your custom MCP configuration (gitignored) 320 | ``` 321 | 322 | ## 🛠️ Built With 323 | 324 | - [Next.js](https://nextjs.org/) - React framework 325 | - [TypeScript](https://www.typescriptlang.org/) - Type safety 326 | - [Tailwind CSS](https://tailwindcss.com/) - Styling 327 | - [Supabase](https://supabase.com/) - Authentication & Database 328 | - [Stripe](https://stripe.com/) - Payments 329 | - [Framer Motion](https://www.framer.com/motion/) - Animations 330 | 331 | ## 🔧 Configuration 332 | 333 | ### Tailwind Configuration 334 | 335 | The template includes a custom Tailwind configuration with: 336 | - Custom colors 337 | - Dark mode support 338 | - Extended theme options 339 | - Custom animations 340 | 341 | ### Authentication 342 | 343 | Authentication is handled through Supabase with support for: 344 | - Email/Password 345 | - Google OAuth 346 | - Magic Links 347 | - Password Reset 348 | 349 | ### Payment Integration 350 | 351 | Stripe integration includes: 352 | - Subscription management 353 | - Trial periods 354 | - Webhook handling 355 | - Payment status tracking 356 | 357 | ## 🤝 Contributing 358 | 359 | 1. Fork the repository 360 | 2. Create your feature branch (`git checkout -b feature/AmazingFeature`) 361 | 3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) 362 | 4. Push to the branch (`git push origin feature/AmazingFeature`) 363 | 5. Open a Pull Request 364 | 365 | ## 📝 License 366 | 367 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 368 | 369 | ## 🙏 Acknowledgments 370 | 371 | - Next.js team for the amazing framework 372 | - Vercel for the deployment platform 373 | - Tailwind CSS team for the utility-first CSS framework 374 | - Supabase team for the backend platform 375 | - Stripe team for the payment infrastructure 376 | - Cursor team for the AI-powered editor and MCP capabilities 377 | - Anthropic for Claude AI and Claude Desktop integration 378 | - MCP framework developers for enabling extended AI capabilities 379 | 380 | ## 📫 Contact 381 | 382 | X - [@ShenSeanChen](https://x.com/ShenSeanChen) 383 | 384 | YouTube - [@SeanTechStories](https://www.youtube.com/@SeanTechStories) 385 | 386 | Discord - [@Sean's Stories](https://discord.gg/TKKPzZheua) 387 | 388 | Instagram - [@SeanTechStories](https://www.instagram.com/sean_tech_stories ) 389 | 390 | Project Link: [https://github.com/ShenSeanChen/launch-stripe-nextjs-supabase](https://github.com/ShenSeanChen/launch-stripe-nextjs-supabase) 391 | 392 | ## 🚀 Deploy 393 | 394 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js). 395 | 396 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/yourusername/your-repo-name) 397 | 398 | --- 399 | 400 | Made with 🔥 by [ShenSeanChen](https://github.com/ShenSeanChen) 401 | -------------------------------------------------------------------------------- /app/api/stripe/cancel/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import type { NextRequest } from 'next/server'; 3 | import Stripe from 'stripe'; 4 | import { supabaseAdmin } from '@/utils/supabase-admin'; 5 | import { withCors } from '@/utils/cors'; 6 | 7 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); 8 | 9 | export const POST = withCors(async function POST(request: NextRequest) { 10 | try { 11 | // Get the subscription ID from the request body 12 | const { subscriptionId } = await request.json(); 13 | 14 | if (!subscriptionId) { 15 | return NextResponse.json( 16 | { error: 'Subscription ID is required' }, 17 | { status: 400 } 18 | ); 19 | } 20 | 21 | // First, get the current subscription status 22 | const currentSubscription = await stripe.subscriptions.retrieve(subscriptionId); 23 | 24 | // If subscription is already canceled, just return success 25 | if (currentSubscription.status === 'canceled') { 26 | return NextResponse.json({ status: 'success', alreadyCanceled: true }); 27 | } 28 | 29 | // If subscription is active or trialing, cancel it 30 | if (['active', 'trialing'].includes(currentSubscription.status)) { 31 | const subscription = await stripe.subscriptions.update(subscriptionId, { 32 | cancel_at_period_end: true 33 | }); 34 | 35 | // Update the subscription in Supabase 36 | const { error: supabaseError } = await supabaseAdmin 37 | .from('subscriptions') 38 | .update({ 39 | cancel_at_period_end: true, 40 | current_period_end: new Date(subscription.current_period_end * 1000).toISOString(), 41 | updated_at: new Date().toISOString() 42 | }) 43 | .eq('stripe_subscription_id', subscriptionId); 44 | 45 | if (supabaseError) { 46 | console.error('Supabase update error:', supabaseError); 47 | throw supabaseError; 48 | } 49 | 50 | return NextResponse.json({ 51 | status: 'success', 52 | subscription: subscription 53 | }); 54 | } 55 | 56 | return NextResponse.json( 57 | { error: 'Subscription cannot be canceled in its current state' }, 58 | { status: 400 } 59 | ); 60 | } catch (error) { 61 | console.error('Subscription cancellation failed:', error); 62 | return NextResponse.json( 63 | { 64 | error: 'Failed to cancel subscription', 65 | details: error instanceof Error ? error.message : 'Unknown error' 66 | }, 67 | { status: 500 } 68 | ); 69 | } 70 | }); -------------------------------------------------------------------------------- /app/api/stripe/reactivate/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import type { NextRequest } from 'next/server'; 3 | import Stripe from 'stripe'; 4 | import { supabaseAdmin } from '@/utils/supabase-admin'; 5 | import { withCors } from '@/utils/cors'; 6 | 7 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); 8 | 9 | export const POST = withCors(async function POST(request: NextRequest) { 10 | try { 11 | const { subscriptionId } = await request.json(); 12 | 13 | if (!subscriptionId) { 14 | return NextResponse.json( 15 | { error: 'Subscription ID is required' }, 16 | { status: 400 } 17 | ); 18 | } 19 | 20 | const subscription = await stripe.subscriptions.update(subscriptionId, { 21 | cancel_at_period_end: false 22 | }); 23 | 24 | await supabaseAdmin 25 | .from('subscriptions') 26 | .update({ 27 | cancel_at_period_end: false, 28 | updated_at: new Date().toISOString() 29 | }) 30 | .eq('stripe_subscription_id', subscriptionId); 31 | 32 | return NextResponse.json({ status: 'success', subscription }); 33 | } catch (error) { 34 | console.error('Subscription reactivation failed:', error); 35 | return NextResponse.json( 36 | { error: 'Failed to reactivate subscription' }, 37 | { status: 500 } 38 | ); 39 | } 40 | }); -------------------------------------------------------------------------------- /app/api/stripe/sync/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import type { NextRequest } from 'next/server'; 3 | import Stripe from 'stripe'; 4 | import { supabaseAdmin } from '@/utils/supabase-admin'; 5 | import { withCors } from '@/utils/cors'; 6 | 7 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); 8 | 9 | export const POST = withCors(async function POST(request: NextRequest) { 10 | try { 11 | console.log('Starting sync process...'); 12 | const { subscriptionId } = await request.json(); 13 | 14 | if (!subscriptionId) { 15 | console.error('No subscription ID provided'); 16 | return NextResponse.json({ error: 'Subscription ID is required' }, { status: 400 }); 17 | } 18 | 19 | // First check if subscription exists in Supabase 20 | const { data: existingSubscription, error: checkError } = await supabaseAdmin 21 | .from('subscriptions') 22 | .select('*') 23 | .eq('stripe_subscription_id', subscriptionId) 24 | .single(); 25 | 26 | if (checkError && checkError.code !== 'PGRST116') { 27 | console.error('Error checking subscription:', checkError); 28 | throw checkError; 29 | } 30 | 31 | // If no subscription exists in database, we need to create it 32 | if (!existingSubscription) { 33 | console.log('No existing subscription found, fetching from Stripe...'); 34 | const stripeSubscription = await stripe.subscriptions.retrieve(subscriptionId); 35 | 36 | // Get customer details to get user_id 37 | const customerResponse = await stripe.customers.retrieve(stripeSubscription.customer as string); 38 | 39 | if (customerResponse.deleted) { 40 | console.error('Customer has been deleted:', stripeSubscription.customer); 41 | throw new Error('Invalid customer'); 42 | } 43 | 44 | const customer = customerResponse as Stripe.Customer; 45 | const userId = customer.metadata?.user_id; 46 | 47 | if (!userId) { 48 | console.error('No user_id in customer metadata:', customer.id); 49 | throw new Error('No user_id found in customer metadata'); 50 | } 51 | 52 | // Create new subscription record 53 | const { error: insertError } = await supabaseAdmin 54 | .from('subscriptions') 55 | .insert({ 56 | user_id: userId, 57 | stripe_customer_id: stripeSubscription.customer as string, 58 | stripe_subscription_id: subscriptionId, 59 | status: stripeSubscription.status, 60 | price_id: stripeSubscription.items.data[0]?.price.id, 61 | current_period_end: new Date(stripeSubscription.current_period_end * 1000).toISOString(), 62 | cancel_at_period_end: stripeSubscription.cancel_at_period_end, 63 | created_at: new Date().toISOString(), 64 | updated_at: new Date().toISOString() 65 | }); 66 | 67 | if (insertError) { 68 | console.error('Error creating subscription:', insertError); 69 | throw insertError; 70 | } 71 | } else { 72 | // Update existing subscription 73 | const stripeSubscription = await stripe.subscriptions.retrieve(subscriptionId); 74 | const { error: updateError } = await supabaseAdmin 75 | .from('subscriptions') 76 | .update({ 77 | status: stripeSubscription.status, 78 | cancel_at_period_end: stripeSubscription.cancel_at_period_end, 79 | current_period_end: new Date(stripeSubscription.current_period_end * 1000).toISOString(), 80 | updated_at: new Date().toISOString() 81 | }) 82 | .eq('stripe_subscription_id', subscriptionId); 83 | 84 | if (updateError) { 85 | console.error('Error updating subscription:', updateError); 86 | throw updateError; 87 | } 88 | } 89 | 90 | return NextResponse.json({ status: 'success' }); 91 | } catch (error) { 92 | console.error('Subscription sync failed:', error); 93 | return NextResponse.json({ 94 | error: 'Failed to sync subscription', 95 | details: error instanceof Error ? error.message : 'Unknown error' 96 | }, { status: 500 }); 97 | } 98 | }); 99 | 100 | // import { NextResponse } from 'next/server'; 101 | // import Stripe from 'stripe'; 102 | // import { supabaseAdmin } from '@/utils/supabase-admin'; 103 | 104 | // const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); 105 | 106 | // export async function POST(req: Request) { 107 | // try { 108 | // const { subscriptionId } = await req.json(); 109 | 110 | // // Fetch current subscription data from Stripe 111 | // const subscription = await stripe.subscriptions.retrieve(subscriptionId); 112 | 113 | // // Update Supabase with the latest Stripe data 114 | // const { error } = await supabaseAdmin 115 | // .from('subscriptions') 116 | // .update({ 117 | // status: subscription.status, 118 | // cancel_at_period_end: subscription.cancel_at_period_end, 119 | // current_period_end: subscription.status === 'canceled' 120 | // ? new Date().toISOString() 121 | // : new Date(subscription.current_period_end * 1000).toISOString(), 122 | // updated_at: new Date().toISOString() 123 | // }) 124 | // .eq('stripe_subscription_id', subscriptionId); 125 | 126 | // if (error) throw error; 127 | 128 | // return NextResponse.json({ status: 'success', subscription }); 129 | // } catch (error) { 130 | // console.error('Subscription sync failed:', error); 131 | // return NextResponse.json( 132 | // { error: 'Failed to sync subscription' }, 133 | // { status: 500 } 134 | // ); 135 | // } 136 | // } -------------------------------------------------------------------------------- /app/api/stripe/test/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import type { NextRequest } from 'next/server'; 3 | import Stripe from 'stripe'; 4 | import { withCors } from '@/utils/cors'; 5 | 6 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); 7 | 8 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 9 | export const GET = withCors(async function GET(request: NextRequest) { 10 | try { 11 | console.log('Testing Stripe connection...'); 12 | console.log('Stripe key starts with:', process.env.STRIPE_SECRET_KEY?.substring(0, 8) + '...'); 13 | 14 | // Just verify the connection works 15 | await stripe.balance.retrieve(); 16 | console.log('Stripe connection successful'); 17 | 18 | return NextResponse.json({ 19 | status: 'success', 20 | message: 'Stripe connection successful', 21 | keyPrefix: process.env.STRIPE_SECRET_KEY?.substring(0, 8) + '...' 22 | }); 23 | } catch (error) { 24 | console.error('Stripe test failed:', error); 25 | return NextResponse.json({ 26 | status: 'error', 27 | message: error instanceof Error ? error.message : 'Unknown error' 28 | }, { status: 500 }); 29 | } 30 | }); -------------------------------------------------------------------------------- /app/api/stripe/webhook/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import type { NextRequest } from 'next/server'; 3 | import Stripe from 'stripe'; 4 | import { supabaseAdmin } from '@/utils/supabase-admin'; 5 | import { withCors } from '@/utils/cors'; 6 | 7 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); 8 | const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!; 9 | 10 | // Helper function for consistent logging 11 | function logWebhookEvent(message: string, data?: unknown) { 12 | const timestamp = new Date().toISOString(); 13 | console.log(`[${timestamp}] WEBHOOK: ${message}`, data ? JSON.stringify(data, null, 2) : ''); 14 | } 15 | 16 | // Define interfaces for stored data 17 | interface StoredSessionData { 18 | userId: string; 19 | customerId: string; 20 | } 21 | 22 | interface StoredSubscriptionData { 23 | id: string; 24 | customer: string; 25 | } 26 | 27 | // Store both checkout sessions and subscriptions temporarily 28 | const checkoutSessionMap = new Map(); 29 | const pendingSubscriptions = new Map(); 30 | 31 | // Need to disable body parsing for Stripe webhooks 32 | export const config = { 33 | api: { 34 | bodyParser: false, 35 | }, 36 | }; 37 | 38 | async function checkExistingSubscription(customerId: string): Promise { 39 | const { data: existingSubs } = await supabaseAdmin 40 | .from('subscriptions') 41 | .select('*') 42 | .eq('stripe_customer_id', customerId) 43 | .in('status', ['active', 'trialing']) 44 | .single(); 45 | 46 | return !!existingSubs; 47 | } 48 | 49 | // Currently Handled Events: 50 | // 1. checkout.session.completed - When a customer completes checkout 51 | // 2. customer.subscription.created - When a new subscription is created 52 | // 3. customer.subscription.updated - When a subscription is updated 53 | // 4. customer.subscription.deleted - When a subscription is cancelled/deleted 54 | // 5. customer.subscription.pending_update_applied - When a pending update is applied 55 | // 6. customer.subscription.pending_update_expired - When a pending update expires 56 | // 7. customer.subscription.trial_will_end - When a trial is about to end 57 | 58 | // Other Important Events You Might Want to Handle: 59 | // Payment Related: 60 | // - invoice.paid - When an invoice is paid successfully 61 | // - invoice.payment_failed - When a payment fails 62 | // - invoice.upcoming - When an invoice is going to be created 63 | // - payment_intent.succeeded - When a payment is successful 64 | // - payment_intent.payment_failed - When a payment fails 65 | 66 | // Customer Related: 67 | // - customer.created - When a new customer is created 68 | // - customer.updated - When customer details are updated 69 | // - customer.deleted - When a customer is deleted 70 | 71 | // Subscription Related: 72 | // - customer.subscription.paused - When a subscription is paused 73 | // - customer.subscription.resumed - When a subscription is resumed 74 | // - customer.subscription.trial_will_end - 3 days before trial ends 75 | 76 | // Checkout Related: 77 | // - checkout.session.async_payment_succeeded - Async payment success 78 | // - checkout.session.async_payment_failed - Async payment failure 79 | // - checkout.session.expired - When checkout session expires 80 | 81 | export const POST = withCors(async function POST(request: NextRequest) { 82 | const body = await request.text(); 83 | const sig = request.headers.get('stripe-signature')!; 84 | 85 | try { 86 | logWebhookEvent('Received webhook request'); 87 | logWebhookEvent('Stripe signature', sig); 88 | 89 | const event = stripe.webhooks.constructEvent(body, sig, webhookSecret); 90 | logWebhookEvent(`Event received: ${event.type}`, event.data.object); 91 | 92 | switch (event.type) { 93 | case 'checkout.session.completed': { 94 | const session = event.data.object as Stripe.Checkout.Session; 95 | 96 | // Check for existing active subscription 97 | const hasActiveSubscription = await checkExistingSubscription(session.customer as string); 98 | 99 | if (hasActiveSubscription) { 100 | logWebhookEvent('Duplicate subscription attempt blocked', { 101 | customerId: session.customer, 102 | sessionId: session.id 103 | }); 104 | 105 | // Cancel the new subscription immediately 106 | if (session.subscription) { 107 | await stripe.subscriptions.cancel(session.subscription as string); 108 | } 109 | 110 | return NextResponse.json({ 111 | status: 'blocked', 112 | message: 'Customer already has an active subscription' 113 | }); 114 | } 115 | 116 | logWebhookEvent('Processing checkout.session.completed', { 117 | sessionId: session.id, 118 | clientReferenceId: session.client_reference_id, 119 | customerId: session.customer, 120 | subscriptionId: session.subscription 121 | }); 122 | 123 | if (!session.client_reference_id || !session.customer || !session.subscription) { 124 | logWebhookEvent('Missing required session data', { 125 | clientReferenceId: session.client_reference_id, 126 | customerId: session.customer, 127 | subscriptionId: session.subscription 128 | }); 129 | return NextResponse.json({ error: 'Invalid session data' }, { status: 400 }); 130 | } 131 | 132 | try { 133 | const subscription = await createSubscription( 134 | session.subscription as string, 135 | session.client_reference_id!, 136 | session.customer as string 137 | ); 138 | logWebhookEvent('Successfully created subscription', subscription); 139 | } catch (error) { 140 | logWebhookEvent('Failed to create subscription', error); 141 | throw error; 142 | } 143 | break; 144 | } 145 | 146 | case 'customer.subscription.created': { 147 | const subscription = event.data.object as Stripe.Subscription; 148 | 149 | // Check if we have the session data already 150 | const sessionData = checkoutSessionMap.get(subscription.id); 151 | if (sessionData) { 152 | // We can create the subscription now 153 | await createSubscription( 154 | subscription.id, 155 | sessionData.userId, 156 | sessionData.customerId 157 | ); 158 | checkoutSessionMap.delete(subscription.id); 159 | } else { 160 | // Store the subscription data until we get the session 161 | pendingSubscriptions.set(subscription.id, { 162 | id: subscription.id, 163 | customer: subscription.customer as string 164 | }); 165 | } 166 | break; 167 | } 168 | 169 | case 'customer.subscription.updated': 170 | case 'customer.subscription.deleted': 171 | case 'customer.subscription.pending_update_applied': 172 | case 'customer.subscription.pending_update_expired': 173 | case 'customer.subscription.trial_will_end': { 174 | const subscription = event.data.object as Stripe.Subscription; 175 | 176 | await supabaseAdmin 177 | .from('subscriptions') 178 | .update({ 179 | status: subscription.status, 180 | cancel_at_period_end: subscription.cancel_at_period_end, 181 | current_period_end: new Date(subscription.current_period_end * 1000).toISOString(), 182 | updated_at: new Date().toISOString() 183 | }) 184 | .eq('stripe_subscription_id', subscription.id); 185 | 186 | break; 187 | } 188 | 189 | case 'customer.subscription.deleted': { 190 | const subscription = event.data.object as Stripe.Subscription; 191 | 192 | await supabaseAdmin 193 | .from('subscriptions') 194 | .update({ 195 | status: subscription.status, 196 | cancel_at_period_end: false, 197 | current_period_end: new Date().toISOString(), 198 | updated_at: new Date().toISOString() 199 | }) 200 | .eq('stripe_subscription_id', subscription.id); 201 | 202 | break; 203 | } 204 | 205 | // Note: You might want to add handlers for these common events: 206 | // case 'invoice.paid': { 207 | // const invoice = event.data.object as Stripe.Invoice; 208 | // // Handle successful payment 209 | // } 210 | 211 | // case 'invoice.payment_failed': { 212 | // const invoice = event.data.object as Stripe.Invoice; 213 | // // Handle failed payment, notify user 214 | // } 215 | 216 | // case 'customer.subscription.trial_will_end': { 217 | // const subscription = event.data.object as Stripe.Subscription; 218 | // // Notify user about trial ending 219 | // } 220 | } 221 | 222 | return NextResponse.json({ received: true }); 223 | } catch (err) { 224 | logWebhookEvent('Webhook error', err); 225 | return NextResponse.json( 226 | { error: 'Webhook handler failed' }, 227 | { status: 400 } 228 | ); 229 | } 230 | }); 231 | 232 | async function createSubscription(subscriptionId: string, userId: string, customerId: string) { 233 | logWebhookEvent('Starting createSubscription', { subscriptionId, userId, customerId }); 234 | 235 | try { 236 | const stripeSubscription = await stripe.subscriptions.retrieve(subscriptionId); 237 | logWebhookEvent('Retrieved Stripe subscription', stripeSubscription); 238 | 239 | const { data: existingData, error: checkError } = await supabaseAdmin 240 | .from('subscriptions') 241 | .select('*') 242 | .eq('stripe_subscription_id', subscriptionId) 243 | .single(); 244 | 245 | if (checkError) { 246 | logWebhookEvent('Error checking existing subscription', checkError); 247 | } 248 | 249 | if (existingData) { 250 | logWebhookEvent('Found existing subscription', existingData); 251 | const { error: updateError } = await supabaseAdmin 252 | .from('subscriptions') 253 | .update({ 254 | status: stripeSubscription.status, 255 | current_period_end: new Date(stripeSubscription.current_period_end * 1000).toISOString(), 256 | cancel_at_period_end: stripeSubscription.cancel_at_period_end, 257 | updated_at: new Date().toISOString() 258 | }) 259 | .eq('stripe_subscription_id', subscriptionId) 260 | .select() 261 | .single(); 262 | 263 | if (updateError) { 264 | logWebhookEvent('Error updating existing subscription', updateError); 265 | throw updateError; 266 | } 267 | return existingData; 268 | } 269 | 270 | logWebhookEvent('Creating new subscription record'); 271 | const { data, error: insertError } = await supabaseAdmin 272 | .from('subscriptions') 273 | .insert({ 274 | user_id: userId, 275 | stripe_customer_id: customerId, 276 | stripe_subscription_id: subscriptionId, 277 | status: stripeSubscription.status, 278 | price_id: stripeSubscription.items.data[0]?.price.id, 279 | current_period_end: new Date(stripeSubscription.current_period_end * 1000).toISOString(), 280 | cancel_at_period_end: stripeSubscription.cancel_at_period_end, 281 | created_at: new Date().toISOString(), 282 | updated_at: new Date().toISOString() 283 | }) 284 | .select() 285 | .single(); 286 | 287 | if (insertError) { 288 | logWebhookEvent('Error inserting new subscription', insertError); 289 | throw insertError; 290 | } 291 | 292 | logWebhookEvent('Successfully created new subscription', data); 293 | return data; 294 | } catch (error) { 295 | logWebhookEvent('Error in createSubscription', error); 296 | throw error; 297 | } 298 | } -------------------------------------------------------------------------------- /app/api/user/delete/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import type { NextRequest } from 'next/server'; 3 | import Stripe from 'stripe'; 4 | import { supabaseAdmin } from '@/utils/supabase-admin'; 5 | import { withCors } from '@/utils/cors'; 6 | 7 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); 8 | 9 | export const DELETE = withCors(async function DELETE(request: NextRequest) { 10 | try { 11 | const { searchParams } = new URL(request.url); 12 | const userId = searchParams.get('userId'); 13 | 14 | if (!userId) { 15 | return NextResponse.json({ error: 'User ID is required' }, { status: 400 }); 16 | } 17 | 18 | console.log('Starting account soft-deletion for user:', userId); 19 | 20 | // 1. Cancel Stripe subscriptions if they exist 21 | const { data: subscriptionsData, error: subError } = await supabaseAdmin 22 | .from('subscriptions') 23 | .select('stripe_subscription_id, status') 24 | .eq('user_id', userId); 25 | 26 | if (subError) { 27 | console.error('Subscription fetch error:', subError); 28 | } else if (subscriptionsData) { 29 | for (const sub of subscriptionsData) { 30 | if (sub.stripe_subscription_id && (sub.status === 'active' || sub.status === 'trialing')) { 31 | try { 32 | await stripe.subscriptions.cancel(sub.stripe_subscription_id); 33 | console.log('Stripe subscription cancelled:', sub.stripe_subscription_id); 34 | } catch (stripeError) { 35 | console.error('Stripe cancellation error:', stripeError); 36 | } 37 | } 38 | } 39 | } 40 | 41 | // 2. Soft delete the profile 42 | const { error: profileError } = await supabaseAdmin 43 | .from('users') 44 | .update({ 45 | deleted_at: new Date().toISOString(), 46 | is_deleted: true 47 | }) 48 | .eq('id', userId); 49 | 50 | if (profileError) { 51 | console.error('Profile update error:', profileError); 52 | return NextResponse.json( 53 | { error: 'Failed to update profile', details: profileError }, 54 | { status: 500 } 55 | ); 56 | } 57 | 58 | // 3. Mark subscriptions as canceled 59 | const { error: subscriptionUpdateError } = await supabaseAdmin 60 | .from('subscriptions') 61 | .update({ 62 | deleted_at: new Date().toISOString(), 63 | status: 'canceled' 64 | }) 65 | .eq('user_id', userId); 66 | 67 | if (subscriptionUpdateError) { 68 | console.error('Subscription update error:', subscriptionUpdateError); 69 | } 70 | 71 | console.log('Account soft-deletion completed successfully'); 72 | return NextResponse.json({ success: true }); 73 | } catch (error) { 74 | console.error('Error in account soft-deletion:', error); 75 | return NextResponse.json( 76 | { 77 | error: 'Failed to process account deletion', 78 | details: error instanceof Error ? error.message : 'Unknown error' 79 | }, 80 | { status: 500 } 81 | ); 82 | } 83 | }); -------------------------------------------------------------------------------- /app/auth/callback/route.ts: -------------------------------------------------------------------------------- 1 | import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; 2 | import { cookies } from 'next/headers'; 3 | import { NextResponse } from 'next/server'; 4 | 5 | export async function GET(request: Request) { 6 | console.log('AuthCallback: Processing callback'); 7 | const requestUrl = new URL(request.url); 8 | const code = requestUrl.searchParams.get('code'); 9 | const next = requestUrl.searchParams.get('next'); 10 | 11 | if (code) { 12 | console.log('AuthCallback: Exchanging code for session'); 13 | const supabase = createRouteHandlerClient({ cookies }); 14 | const { error } = await supabase.auth.exchangeCodeForSession(code); 15 | 16 | if (error) { 17 | console.error('AuthCallback: Error:', error); 18 | return NextResponse.redirect(new URL('/login?error=auth-failed', requestUrl.origin)); 19 | } 20 | 21 | // Redirect to the next page if provided, otherwise go to home 22 | if (next) { 23 | console.log('AuthCallback: Redirecting to:', next); 24 | return NextResponse.redirect(new URL(next, requestUrl.origin)); 25 | } 26 | 27 | console.log('AuthCallback: Success, redirecting to home'); 28 | return NextResponse.redirect(new URL('/dashboard', requestUrl.origin)); 29 | } 30 | 31 | console.log('AuthCallback: No code present, redirecting to login'); 32 | return NextResponse.redirect(new URL('/login', requestUrl.origin)); 33 | } -------------------------------------------------------------------------------- /app/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | // import { useWebSocket } from '@/contexts/WebSocketContext'; 4 | import { useEffect, useState } from 'react'; 5 | import { supabase } from '@/utils/supabase'; 6 | import { useAuth } from '@/contexts/AuthContext'; 7 | 8 | 9 | import { useRouter } from 'next/navigation'; 10 | import { useSubscription } from '@/hooks/useSubscription'; 11 | // import { OnboardingTour } from '@/components/OnboardingTour'; 12 | import { useTrialStatus } from '@/hooks/useTrialStatus'; 13 | import { motion } from 'framer-motion'; 14 | import { 15 | BarChart3, 16 | Users, 17 | CreditCard, 18 | Settings, 19 | PlusCircle, 20 | Clock, 21 | TrendingUp, 22 | Activity 23 | } from 'lucide-react'; 24 | 25 | const AUTH_TIMEOUT = 15000; // 15 seconds 26 | 27 | // Dashboard metrics data 28 | const dashboardMetrics = [ 29 | { 30 | title: "Total Users", 31 | value: "1,234", 32 | change: "+12.3%", 33 | icon: , 34 | trend: "up" 35 | }, 36 | { 37 | title: "Revenue", 38 | value: "$12.4k", 39 | change: "+8.2%", 40 | icon: , 41 | trend: "up" 42 | }, 43 | { 44 | title: "Active Sessions", 45 | value: "432", 46 | change: "-3.1%", 47 | icon: , 48 | trend: "down" 49 | }, 50 | { 51 | title: "Growth Rate", 52 | value: "18.2%", 53 | change: "+2.4%", 54 | icon: , 55 | trend: "up" 56 | } 57 | ]; 58 | 59 | // Recent activity data 60 | const recentActivity = [ 61 | { 62 | id: 1, 63 | action: "New user signup", 64 | timestamp: "2 minutes ago", 65 | icon: 66 | }, 67 | { 68 | id: 2, 69 | action: "Payment processed", 70 | timestamp: "15 minutes ago", 71 | icon: 72 | }, 73 | { 74 | id: 3, 75 | action: "Settings updated", 76 | timestamp: "1 hour ago", 77 | icon: 78 | }, 79 | { 80 | id: 4, 81 | action: "Session completed", 82 | timestamp: "2 hours ago", 83 | icon: 84 | } 85 | ]; 86 | 87 | export default function Dashboard() { 88 | 89 | 90 | // const { isConnected } = useWebSocket(); 91 | // const [fullResponse, setFullResponse] = useState(''); 92 | const { user, isSubscriber, isLoading: isAuthLoading } = useAuth(); 93 | const router = useRouter(); 94 | const { subscription, isLoading: isSubLoading, fetchSubscription } = useSubscription(); 95 | const [hasCheckedSubscription, setHasCheckedSubscription] = useState(false); 96 | const [hasCompletedOnboarding, setHasCompletedOnboarding] = useState(false); 97 | const { isInTrial, isLoading: isTrialLoading } = useTrialStatus(); 98 | const [authTimeout, setAuthTimeout] = useState(false); 99 | 100 | // Add new states for dashboard functionality 101 | // const [repositories, setRepositories] = useState([]); 102 | // const [feedbackSources, setFeedbackSources] = useState([]); 103 | // const [recentFeedback, setRecentFeedback] = useState([]); 104 | // const [pendingPRs, setPendingPRs] = useState([]); 105 | 106 | // First check - Subscription and trial check 107 | useEffect(() => { 108 | if (isSubLoading || isTrialLoading) return; 109 | 110 | const hasValidSubscription = ['active', 'trialing'].includes(subscription?.status || ''); 111 | 112 | console.log('Access check isInTrial:', { 113 | hasSubscription: !!subscription, 114 | status: subscription?.status, 115 | isInTrial: isInTrial, 116 | validUntil: subscription?.current_period_end 117 | }); 118 | 119 | // Only redirect if there's no valid subscription AND no valid trial 120 | if (!hasValidSubscription && !isInTrial) { 121 | console.log('No valid subscription or trial, redirecting'); 122 | router.replace('/profile'); 123 | } 124 | }, [subscription, isSubLoading, isTrialLoading, router, isInTrial]); 125 | 126 | // Second check - Auth check 127 | useEffect(() => { 128 | if (isAuthLoading || isTrialLoading) return; 129 | 130 | console.log('Access check:', { 131 | isSubscriber, 132 | hasCheckedSubscription, 133 | isInTrial: isInTrial, 134 | authLoading: isAuthLoading, 135 | }); 136 | 137 | if (!hasCheckedSubscription) { 138 | setHasCheckedSubscription(true); 139 | 140 | // Allow access for both subscribers and trial users 141 | if (!user || (!isSubscriber && !isInTrial && !isAuthLoading)) { 142 | console.log('No valid subscription or trial, redirecting'); 143 | router.replace('/profile'); 144 | } 145 | } 146 | }, [isSubscriber, isAuthLoading, hasCheckedSubscription, router, user, subscription, isTrialLoading, isInTrial]); 147 | 148 | // Add refresh effect 149 | useEffect(() => { 150 | const refreshSubscription = async () => { 151 | await fetchSubscription(); 152 | setHasCheckedSubscription(true); 153 | }; 154 | 155 | if (user?.id) { 156 | refreshSubscription(); 157 | } 158 | }, [user?.id, fetchSubscription]); 159 | 160 | useEffect(() => { 161 | if (user?.id) { 162 | // Check if user has completed onboarding 163 | const checkOnboarding = async () => { 164 | const { data } = await supabase 165 | .from('user_preferences') 166 | .select('has_completed_onboarding') 167 | .eq('user_id', user.id) 168 | .single(); 169 | 170 | setHasCompletedOnboarding(!!data?.has_completed_onboarding); 171 | console.log('hasCompletedOnboarding: ', hasCompletedOnboarding) 172 | }; 173 | 174 | checkOnboarding(); 175 | } 176 | }, [user?.id, hasCompletedOnboarding]); 177 | 178 | useEffect(() => { 179 | const timer = setTimeout(() => { 180 | if (!user && (isAuthLoading || isTrialLoading)) { 181 | setAuthTimeout(true); 182 | } 183 | }, AUTH_TIMEOUT); 184 | 185 | return () => clearTimeout(timer); 186 | }, [user, isAuthLoading, isTrialLoading]); 187 | 188 | // useEffect(() => { 189 | // if (!hasCompletedOnboarding) { 190 | // router.push('/onboarding'); 191 | // } 192 | // }, [hasCompletedOnboarding, router]); 193 | 194 | // Update the loading check 195 | if (!user && (isAuthLoading || isTrialLoading) && !hasCheckedSubscription) { 196 | console.log('user: ', user) 197 | console.log('isAuthLoading: ', isAuthLoading) 198 | console.log('hasCheckedSubscription: ', hasCheckedSubscription) 199 | return ( 200 |
201 |
202 |
203 |

204 | {authTimeout ? 205 | "Taking longer than usual? Try refreshing the page 😊." : 206 | "Verifying access..."} 207 |

208 |
209 |
210 | ); 211 | } 212 | 213 | 214 | return ( 215 |
216 | {/* Dashboard Header */} 217 |
218 |
219 |
220 |

221 | Dashboard Overview 222 |

223 |
224 | 225 | {isInTrial ? "Trial Period" : "Premium Plan"} 226 | 227 |
228 |
229 |
230 |
231 | 232 | {/* Dashboard Content */} 233 |
234 | {/* Metrics Grid */} 235 |
236 | {dashboardMetrics.map((metric, index) => ( 237 | 244 |
245 |
246 | {metric.icon} 247 |
248 | 251 | {metric.change} 252 | 253 |
254 |

255 | {metric.value} 256 |

257 |

258 | {metric.title} 259 |

260 |
261 | ))} 262 |
263 | 264 | {/* Activity Feed */} 265 |
266 | {/* Chart Section */} 267 |
268 |
269 |

270 | Analytics Overview 271 |

272 | 273 |
274 |
275 |

276 | Chart Placeholder 277 |

278 |
279 |
280 | 281 | {/* Recent Activity */} 282 |
283 |

284 | Recent Activity 285 |

286 |
287 | {recentActivity.map((activity) => ( 288 | 294 |
295 | {activity.icon} 296 |
297 |
298 |

299 | {activity.action} 300 |

301 |

302 | {activity.timestamp} 303 |

304 |
305 |
306 | ))} 307 |
308 |
309 |
310 |
311 |
312 | ); 313 | } -------------------------------------------------------------------------------- /app/favicon-vercel.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShenSeanChen/launch-mvp-stripe-nextjs-supabase/de84cfe8763f6043ae8095bfa8bdca06b3a83379/app/favicon-vercel.ico -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShenSeanChen/launch-mvp-stripe-nextjs-supabase/de84cfe8763f6043ae8095bfa8bdca06b3a83379/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --background: #F8F9FA; 7 | --foreground: #2F3E46; 8 | } 9 | 10 | @media (prefers-color-scheme: dark) { 11 | :root { 12 | --background: #1A1B26; 13 | --foreground: #F8F9FA; 14 | } 15 | } 16 | 17 | body { 18 | color: var(--foreground); 19 | background: var(--background); 20 | font-family: 'Inter', sans-serif; 21 | } 22 | 23 | /* Add some cooking-themed utility classes */ 24 | @layer components { 25 | .recipe-card { 26 | @apply bg-white dark:bg-neutral-dark rounded-lg shadow-sm border border-primary/10 dark:border-primary/20 transition-all duration-300 hover:shadow-md; 27 | } 28 | 29 | .cooking-button { 30 | @apply bg-primary hover:bg-primary-dark text-white rounded-lg transition-all duration-300 transform hover:scale-102 active:scale-98; 31 | } 32 | 33 | .accent-button { 34 | @apply bg-secondary hover:bg-secondary-dark text-white rounded-lg transition-all duration-300; 35 | } 36 | } 37 | 38 | /* Hide scrollbar styles */ 39 | .hide-scrollbar::-webkit-scrollbar { 40 | display: none; 41 | } 42 | 43 | .hide-scrollbar { 44 | -ms-overflow-style: none; 45 | scrollbar-width: none; 46 | } -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Geist } from "next/font/google"; 4 | import "./globals.css"; 5 | import { AuthProvider } from '@/contexts/AuthContext'; 6 | import TopBar from '../components/TopBar'; 7 | import ProtectedRoute from '@/contexts/ProtectedRoute'; 8 | import { Analytics } from "@vercel/analytics/react" 9 | // import { PostHogProvider } from '@/contexts/PostHogContext'; 10 | // import { PostHogErrorBoundary } from '@/components/PostHogErrorBoundary'; 11 | 12 | const geist = Geist({ subsets: ['latin'] }); 13 | 14 | export default function RootLayout({ 15 | children, 16 | }: Readonly<{ 17 | children: React.ReactNode; 18 | }>) { 19 | return ( 20 | 21 | 22 | 23 | {/* 24 | */} 25 | 26 | 27 | 28 |
{children}
29 |
30 |
31 | {/*
32 |
*/} 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /app/login/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState, useEffect } from 'react'; 4 | import { useAuth } from '@/contexts/AuthContext'; 5 | import { useRouter } from 'next/navigation'; 6 | import { LoginForm } from '@/components/LoginForm'; 7 | 8 | export default function LoginPage() { 9 | const { user, signInWithGoogle, signInWithEmail, signUpWithEmail } = useAuth(); 10 | const router = useRouter(); 11 | const [error, setError] = useState(''); 12 | const [isLoading, setIsLoading] = useState(false); 13 | 14 | useEffect(() => { 15 | if (user) { 16 | router.replace('/dashboard'); 17 | } else { 18 | setIsLoading(false); 19 | } 20 | }, [user, router]); 21 | 22 | const handleSubmit = async (email: string, password: string, isSignUp: boolean) => { 23 | setError(''); 24 | setIsLoading(true); 25 | 26 | try { 27 | if (isSignUp) { 28 | const { data, error } = await signUpWithEmail(email, password); 29 | if (error) throw error; 30 | 31 | // Check if the user needs to verify their email 32 | if (data?.user && !data.user.email_confirmed_at) { 33 | router.replace(`/verify-email?email=${encodeURIComponent(email)}`); 34 | return; 35 | } 36 | 37 | router.replace('/dashboard'); 38 | } else { 39 | await signInWithEmail(email, password); 40 | router.replace('/dashboard'); 41 | } 42 | } catch (error) { 43 | setError(error instanceof Error ? error.message : 'Authentication failed'); 44 | } finally { 45 | setIsLoading(false); 46 | } 47 | }; 48 | 49 | if (isLoading) { 50 | return ( 51 |
52 |
Loading...
53 |
54 | ); 55 | } 56 | 57 | return ( 58 |
59 |
60 | {/*

61 | NextTemp 62 |

*/} 63 | 69 |
70 |
71 | ); 72 | } -------------------------------------------------------------------------------- /app/metadata.ts: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | 3 | export const metadata: Metadata = { 4 | title: "Sean Dev Template", 5 | description: "Your app description", 6 | }; -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useAuth } from '@/contexts/AuthContext'; 4 | import { PricingSection } from '@/components/PricingSection'; 5 | import { useTrialStatus } from '@/hooks/useTrialStatus'; 6 | // import { DemoWidget } from '@/components/DemoWidget'; 7 | // import { MetricCard } from '@/components/MetricCard'; 8 | import { TypewriterEffect } from '@/components/TypewriterEffect'; 9 | import { FaReddit } from 'react-icons/fa'; 10 | import { 11 | FaGithub, 12 | FaDiscord, 13 | FaProductHunt, 14 | FaXTwitter, 15 | FaHackerNews, 16 | FaInstagram, 17 | FaTiktok, 18 | FaYoutube 19 | } from 'react-icons/fa6'; 20 | import { 21 | Lock, CreditCard, Moon 22 | } from 'lucide-react'; 23 | import { motion, useScroll, useTransform } from 'framer-motion'; 24 | import { useInView } from 'react-intersection-observer'; 25 | import { useState } from 'react'; 26 | import { useRouter } from 'next/navigation'; 27 | import { Link as ScrollLink } from 'react-scroll'; 28 | import { VideoModal } from '@/components/VideoModal'; 29 | 30 | /* eslint-disable @typescript-eslint/no-unused-vars */ 31 | 32 | // Update workflowSteps to be more generic 33 | const workflowSteps = [ 34 | { 35 | title: "Step One", 36 | description: "First step of your workflow", 37 | preview: 38 | }, 39 | { 40 | title: "Step Two", 41 | description: "Second step of your workflow", 42 | preview: 43 | }, 44 | { 45 | title: "Step Three", 46 | description: "Third step of your workflow", 47 | preview: 48 | }, 49 | { 50 | title: "Step Four", 51 | description: "Fourth step of your workflow", 52 | preview: 53 | } 54 | ]; 55 | 56 | // Update platforms to be generic 57 | const platforms = [ 58 | { name: 'Platform 1', icon: FaGithub }, 59 | { name: 'Platform 2', icon: FaDiscord }, 60 | { name: 'Platform 3', icon: FaReddit }, 61 | { name: 'Platform 4', icon: FaProductHunt }, 62 | { name: 'Platform 5', icon: FaXTwitter }, 63 | { name: 'Platform 6', icon: FaHackerNews }, 64 | { name: 'Platform 7', icon: FaInstagram }, 65 | { name: 'Platform 8', icon: FaTiktok }, 66 | { name: 'Platform 9', icon: FaYoutube } 67 | ]; 68 | 69 | // Update workflowSections to be generic 70 | const workflowSections = [ 71 | { 72 | id: "overview", 73 | title: "Overview", 74 | description: "Everything you need to build modern SaaS applications", 75 | bgColor: "bg-white dark:bg-[#0B1120]" 76 | }, 77 | { 78 | id: "authentication", 79 | title: "Authentication", 80 | description: "Secure user authentication with multiple providers", 81 | bgColor: "bg-slate-50 dark:bg-[#0B1120]", 82 | metrics: [ 83 | { label: "Auth Providers", value: "5+" }, 84 | { label: "Setup Time", value: "2min" }, 85 | { label: "Security", value: "A+" } 86 | ] 87 | }, 88 | { 89 | id: "payments", 90 | title: "Payments", 91 | description: "Seamless payment integration with Stripe", 92 | bgColor: "bg-white dark:bg-[#0B1120]", 93 | metrics: [ 94 | { label: "Integration", value: "1-Click" }, 95 | { label: "Providers", value: "Stripe" }, 96 | { label: "Setup Time", value: "5min" } 97 | ] 98 | }, 99 | { 100 | id: "database", 101 | title: "Database", 102 | description: "Powerful database with Supabase integration", 103 | bgColor: "bg-slate-50 dark:bg-[#0B1120]", 104 | metrics: [ 105 | { label: "Database", value: "PostgreSQL" }, 106 | { label: "Real-time", value: "Yes" }, 107 | { label: "Security", value: "RLS" } 108 | ] 109 | }, 110 | { 111 | id: "features", 112 | title: "Features", 113 | description: "Additional features to enhance your application", 114 | bgColor: "bg-white dark:bg-[#0B1120]", 115 | metrics: [ 116 | { label: "Dark Mode", value: "Built-in" }, 117 | { label: "Components", value: "50+" }, 118 | { label: "TypeScript", value: "100%" } 119 | ] 120 | }, 121 | { 122 | id: "pricing", 123 | title: "Pricing", 124 | description: "Simple, transparent pricing for your needs", 125 | bgColor: "bg-slate-50 dark:bg-[#0B1120]" 126 | } 127 | ]; 128 | 129 | // Custom Hook to create section progress values 130 | function useSectionProgressValues(numSections: number) { 131 | const { scrollYProgress } = useScroll(); 132 | 133 | // Create all transforms at once, at the top level 134 | const section1Progress = useTransform( 135 | scrollYProgress, 136 | [0 / numSections, 1 / numSections], 137 | [0, 1] 138 | ); 139 | const section2Progress = useTransform( 140 | scrollYProgress, 141 | [1 / numSections, 2 / numSections], 142 | [0, 1] 143 | ); 144 | const section3Progress = useTransform( 145 | scrollYProgress, 146 | [2 / numSections, 3 / numSections], 147 | [0, 1] 148 | ); 149 | const section4Progress = useTransform( 150 | scrollYProgress, 151 | [3 / numSections, 4 / numSections], 152 | [0, 1] 153 | ); 154 | 155 | return [section1Progress, section2Progress, section3Progress, section4Progress]; 156 | } 157 | 158 | // Feature cards data 159 | const featureCards = [ 160 | { 161 | title: "Authentication", 162 | description: "Supabase auth with social providers", 163 | icon: , 164 | bgGradient: "from-blue-500/10 to-purple-500/10" 165 | }, 166 | { 167 | title: "Payments", 168 | description: "Stripe subscription management", 169 | icon: , 170 | bgGradient: "from-green-500/10 to-emerald-500/10" 171 | }, 172 | { 173 | title: "Dark Mode", 174 | description: "Built-in theme management", 175 | icon: , 176 | bgGradient: "from-orange-500/10 to-red-500/10" 177 | } 178 | ]; 179 | 180 | export default function LandingPage() { 181 | const { user } = useAuth(); 182 | const { isInTrial } = useTrialStatus(); 183 | const [activeSection, setActiveSection] = useState("overview"); 184 | const sectionProgressValues = useSectionProgressValues(workflowSections.length); 185 | 186 | const router = useRouter(); 187 | 188 | const [dashboardRef, inView] = useInView({ 189 | triggerOnce: true, 190 | threshold: 0.1 191 | }); 192 | 193 | const { scrollYProgress } = useScroll(); 194 | 195 | const [isVideoModalOpen, setIsVideoModalOpen] = useState(false); 196 | 197 | return ( 198 |
199 | {/* Enhanced Sticky Navigation */} 200 | 237 | 238 | {/* Hero Section - Now acts as Overview */} 239 |
240 |
241 | 242 |
243 |
244 | {/* Header Content */} 245 |
246 |

247 | Next.js + Stripe + Supabase 248 | Production-Ready Template 249 |

250 |

251 | Start building with authentication and payments in minutes. 252 |

253 | 254 | {/* CTA Buttons */} 255 |
256 | setIsVideoModalOpen(true)} 260 | className="px-8 py-3 bg-primary hover:bg-primary-dark text-white rounded-lg shadow-lg hover:shadow-xl transition-all" 261 | > 262 | Watch Demo 263 | 264 | 270 |
271 |
272 | 273 | {/* Combined Preview: Code + Workflow Steps */} 274 |
275 | {/* Code Preview */} 276 |
277 |
278 |                   
279 |                      {
283 |   const { coffee } = useCoffee();
284 |   const { bugs } = useCode();
285 |   
286 |   return (
287 |     
288 | 289 | {coffee ? '⚡️ Coding Mode' : '😴 Need Coffee'} 290 | {bugs === 0 ? '🎉 No Bugs!' : '🐛 Debug Time'} 291 | 292 |
293 | );`} /> 294 |
295 |
296 |
297 | 298 | {/* Workflow Steps */} 299 |
300 | {workflowSteps.map((step, index) => ( 301 | 306 |
307 | {index + 1} 308 |
309 |
310 |

{step.title}

311 |

{step.description}

312 |
313 |
314 | ))} 315 |
316 |
317 |
318 |
319 |
320 | 321 | {/* Other sections */} 322 | {workflowSections.slice(1).map((section, index) => ( 323 | setActiveSection(section.id)} 331 | > 332 |
333 | {/* Section header */} 334 |
335 |

336 | {section.title} 337 |

338 |

339 | {section.description} 340 |

341 |
342 | 343 | {/* Clean Metrics Display */} 344 | {section.metrics && ( 345 |
346 | {section.metrics.map((metric, i) => ( 347 | 354 |
355 | {metric.value} 356 |
357 |
358 | {metric.label} 359 |
360 |
361 | ))} 362 |
363 | )} 364 | 365 | {/* Pricing Section */} 366 | {section.id === "pricing" && } 367 |
368 |
369 | ))} 370 | 371 | {/* Enhanced CTA Section */} 372 | 377 |
378 |
379 |
380 |
381 | 386 | Ready to Get Started? 387 | 388 |

389 | Start using our product today 390 |

391 | 392 |
393 | setIsVideoModalOpen(true)} 397 | className="px-8 py-3 bg-primary hover:bg-primary-dark text-white rounded-lg shadow-lg hover:shadow-xl transition-all" 398 | > 399 | Watch Demo 400 | 401 | router.push('/dashboard')} 405 | className="px-8 py-3 bg-white dark:bg-neutral-dark hover:bg-slate-50 dark:hover:bg-neutral-darker text-primary dark:text-primary-light border-2 border-primary dark:border-primary-light rounded-lg shadow-lg hover:shadow-xl transition-all" 406 | > 407 | Start Free Trial 408 | 409 |
410 |
411 |
412 |
413 | 414 | 415 | setIsVideoModalOpen(false)} 418 | videoId="S1cnQG0-LP4" 419 | /> 420 |
421 | ); 422 | } 423 | 424 | -------------------------------------------------------------------------------- /app/pay/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect } from 'react'; 4 | import { useRouter } from 'next/navigation'; 5 | // import { useAuth } from '@/contexts/AuthContext'; 6 | import { useSubscription } from '@/hooks/useSubscription'; 7 | import { StripeBuyButton } from '@/components/StripeBuyButton'; 8 | import { SubscriptionStatus } from '@/components/SubscriptionStatus'; 9 | 10 | export default function PaymentPage() { 11 | // const { user } = useAuth(); 12 | const { subscription, isLoading, error } = useSubscription(); 13 | const router = useRouter(); 14 | 15 | // Redirect if already subscribed 16 | useEffect(() => { 17 | if ( (subscription?.status === 'active' || subscription?.status === 'trialing') && !subscription.cancel_at_period_end) { 18 | const timer = setTimeout(() => { 19 | router.push('/profile'); 20 | }, 3000); 21 | return () => clearTimeout(timer); 22 | } 23 | }, [subscription, router]); 24 | 25 | // Check if user can subscribe 26 | const canSubscribe = !isLoading && 27 | (!subscription || 28 | (subscription.status === 'canceled' && !subscription.cancel_at_period_end)); 29 | 30 | // Add error handling 31 | if (error) { 32 | return ( 33 |
34 |

Error Loading Subscription

35 |

36 | Unable to load subscription information. Please try again later. 37 |

38 | 44 |
45 | ); 46 | } 47 | 48 | if (!canSubscribe) { 49 | return ( 50 |
51 |

Subscription Not Available

52 |

53 | You already have an active or pending subscription. 54 |

55 | 61 |
62 | ); 63 | } 64 | 65 | return ( 66 |
67 |

Complete Your Purchase

68 | 69 | 70 | 71 |
72 | 77 |
78 |
79 | ); 80 | } 81 | 82 | 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /app/profile/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect, useState } from 'react'; 4 | import { useRouter, useSearchParams } from 'next/navigation'; 5 | import { useAuth } from '@/contexts/AuthContext'; 6 | import { useSubscription } from '@/hooks/useSubscription'; 7 | import Link from 'next/link'; 8 | import { AccountManagement } from '@/components/AccountManagement'; 9 | import { ErrorBoundary } from 'react-error-boundary'; 10 | import { Suspense } from 'react'; 11 | import LoadingSpinner from '@/components/LoadingSpinner'; 12 | import { StripeBuyButton } from '@/components/StripeBuyButton'; 13 | import { useTrialStatus } from '@/hooks/useTrialStatus'; 14 | // import { PricingSection } from '@/components/PricingSection'; 15 | // import { StripeBuyButton } from '@/components/StripeBuyButton'; 16 | 17 | function ProfileContent() { 18 | const { user } = useAuth(); 19 | const { subscription, isLoading: isLoadingSubscription, syncWithStripe, fetchSubscription } = useSubscription(); 20 | const router = useRouter(); 21 | const searchParams = useSearchParams(); 22 | const paymentStatus = searchParams.get('payment'); 23 | const [isCancelModalOpen, setIsCancelModalOpen] = useState(false); 24 | const [isCancelling, setIsCancelling] = useState(false); 25 | const [error, setError] = useState(null); 26 | const { isInTrial, trialEndTime } = useTrialStatus(); 27 | 28 | // Show payment success message if redirected from successful payment 29 | useEffect(() => { 30 | if (paymentStatus === 'success') { 31 | // Could add a toast notification here 32 | console.log('Payment successful!'); 33 | } 34 | }, [paymentStatus]); 35 | 36 | // Add error handling for subscription sync 37 | useEffect(() => { 38 | if (subscription?.stripe_subscription_id) { 39 | try { 40 | syncWithStripe(subscription.stripe_subscription_id); 41 | console.log('Subscription synced with Stripe successfully'); 42 | } catch (err: unknown) { 43 | console.error('Error syncing with Stripe:', err); 44 | setError('Unable to load subscription details'); 45 | } 46 | } 47 | }, [syncWithStripe, subscription?.stripe_subscription_id]); 48 | 49 | // Add loading timeout with auto-refresh 50 | useEffect(() => { 51 | let timeoutId: NodeJS.Timeout; 52 | let refreshAttempts = 0; 53 | const MAX_REFRESH_ATTEMPTS = 3; 54 | const REFRESH_INTERVAL = 3000; // 3 seconds 55 | 56 | const attemptRefresh = async () => { 57 | if (refreshAttempts < MAX_REFRESH_ATTEMPTS) { 58 | refreshAttempts++; 59 | console.log(`Attempting auto-refresh (${refreshAttempts}/${MAX_REFRESH_ATTEMPTS})`); 60 | await fetchSubscription(); 61 | 62 | // If still loading, schedule next attempt 63 | if (isLoadingSubscription) { 64 | timeoutId = setTimeout(attemptRefresh, REFRESH_INTERVAL); 65 | } 66 | } else { 67 | setError('Loading subscription is taking longer than expected. Please refresh the page.'); 68 | } 69 | }; 70 | 71 | if (isLoadingSubscription) { 72 | timeoutId = setTimeout(attemptRefresh, REFRESH_INTERVAL); 73 | } 74 | 75 | return () => { 76 | if (timeoutId) clearTimeout(timeoutId); 77 | }; 78 | }, [isLoadingSubscription, fetchSubscription]); 79 | 80 | // Add useEffect for auth check 81 | useEffect(() => { 82 | if (!user) { 83 | router.push('/login'); 84 | } 85 | }, [user, router]); 86 | 87 | // Add refresh effect 88 | useEffect(() => { 89 | if (user?.id) { 90 | fetchSubscription(); 91 | } 92 | }, [user?.id, fetchSubscription]); 93 | 94 | const handleCancelSubscription = async () => { 95 | if (!subscription?.stripe_subscription_id) return; 96 | 97 | setIsCancelling(true); 98 | try { 99 | const response = await fetch('/api/stripe/cancel', { 100 | method: 'POST', 101 | headers: { 'Content-Type': 'application/json' }, 102 | body: JSON.stringify({ 103 | subscriptionId: subscription.stripe_subscription_id 104 | }), 105 | }); 106 | 107 | if (!response.ok) throw new Error('Failed to cancel subscription'); 108 | 109 | setIsCancelModalOpen(false); 110 | router.refresh(); 111 | } catch (error) { 112 | console.error('Error canceling subscription:', error); 113 | } finally { 114 | setIsCancelling(false); 115 | } 116 | }; 117 | 118 | const handleReactivateSubscription = async () => { 119 | if (!subscription?.stripe_subscription_id) return; 120 | 121 | try { 122 | const response = await fetch('/api/stripe/reactivate', { 123 | method: 'POST', 124 | headers: { 'Content-Type': 'application/json' }, 125 | body: JSON.stringify({ 126 | subscriptionId: subscription.stripe_subscription_id 127 | }), 128 | }); 129 | 130 | if (!response.ok) throw new Error('Failed to reactivate subscription'); 131 | 132 | router.refresh(); 133 | } catch (error) { 134 | console.error('Error reactivating subscription:', error); 135 | } 136 | }; 137 | 138 | if (!user) { 139 | return ( 140 |
141 |
142 |
143 |

Redirecting to login...

144 |
145 |
146 | ); 147 | } 148 | 149 | return ( 150 | 153 | Failed to load subscription details. Please try refreshing. 154 |
155 | } 156 | > 157 |
158 | {paymentStatus === 'success' && ( 159 |
160 |

161 | 🎉 Thank you for your subscription! Your payment was successful. 162 |

163 |
164 | )} 165 | 166 |

Profile

167 | 168 | 169 | 170 | {/* Subscription Section */} 171 |
172 |

Subscription Status

173 | {error ? ( 174 |
{error}
175 | ) : isLoadingSubscription ? ( 176 |
177 |
178 | Loading subscription details... 179 |
180 | ) : subscription ? ( 181 |
182 |

183 | Status:{' '} 184 | 185 | {subscription.status.charAt(0).toUpperCase() + subscription.status.slice(1)} 186 | 187 |

188 |

Started: {new Date(subscription.created_at).toLocaleDateString()}

189 | 190 | {subscription.status === 'canceled' ? ( 191 |
192 | 196 | Resubscribe 197 | 198 |
199 | ) : subscription.cancel_at_period_end ? ( 200 |
201 |

202 | Your subscription will end on {new Date(subscription.current_period_end).toLocaleDateString()} 203 |

204 | 210 |
211 | ) : (subscription.status === 'active' || subscription.status === 'trialing') ? ( 212 | 218 | ) : null} 219 |
220 | ) : ( 221 |
222 | {isInTrial ? ( 223 | <> 224 |

225 | You are currently in your 48-hour trial period. Your trial will end on {' '} 226 | {trialEndTime ? new Date(trialEndTime).toLocaleDateString() : 'soon'}. 227 |

228 |

Subscribe now to continue using the app after the trial ends.

229 | 230 | ) : trialEndTime ? ( 231 | <> 232 |
233 |

234 | Your trial period ended on {new Date(trialEndTime).toLocaleDateString()}. 235 |

236 |

Subscribe now to regain access to the cooking experience.

237 |
238 | 239 | ) : ( 240 |

Subscribe to unlock the amazing cooking experience.

241 | )} 242 | 243 | 247 |
248 | )} 249 |
250 | 251 | {/* Show pricing section if user doesn't have an active subscription */} 252 | {/* {(!subscription || subscription.status === 'canceled') && ( 253 | 254 | )} */} 255 | 256 | {/* Cancel Confirmation Modal */} 257 | {isCancelModalOpen && ( 258 |
259 |
260 |

Cancel Subscription?

261 |

262 | You'll continue to have access until the end of your billing period on {new Date(subscription?.current_period_end || '').toLocaleDateString()}. No refunds are provided for cancellations. 263 |

264 |
265 | 272 | 286 |
287 |
288 |
289 | )} 290 |
291 | 292 | ); 293 | }; 294 | 295 | 296 | export default function ProfilePage() { 297 | return ( 298 | }> 299 | 300 | 301 | ); 302 | } 303 | -------------------------------------------------------------------------------- /app/reset-password/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState, useEffect, Suspense } from 'react'; 4 | import { useAuth } from '@/contexts/AuthContext'; 5 | import { useSearchParams } from 'next/navigation'; 6 | import LoadingSpinner from '@/components/LoadingSpinner'; 7 | 8 | function ResetPasswordContent() { 9 | const { supabase } = useAuth(); 10 | const searchParams = useSearchParams(); 11 | const email = searchParams.get('email'); 12 | const [error, setError] = useState(''); 13 | const [success, setSuccess] = useState(false); 14 | const [isLoading, setIsLoading] = useState(false); 15 | 16 | // Automatically trigger reset password if email is present 17 | useEffect(() => { 18 | if (email && !success && !isLoading) { 19 | handleResetPassword(); 20 | } 21 | }, [email]); // eslint-disable-line react-hooks/exhaustive-deps 22 | 23 | const handleResetPassword = async () => { 24 | if (!email) return; 25 | 26 | setIsLoading(true); 27 | setError(''); 28 | 29 | try { 30 | const { error } = await supabase.auth.resetPasswordForEmail(email, { 31 | redirectTo: `${window.location.origin}/update-password#`, 32 | }); 33 | if (error) throw error; 34 | setSuccess(true); 35 | } catch (error) { 36 | setError(error instanceof Error ? error.message : 'Failed to send reset email'); 37 | } finally { 38 | setIsLoading(false); 39 | } 40 | }; 41 | 42 | if (!email) { 43 | return ( 44 |
45 |
46 |
47 |

Invalid Request

48 |

49 | No email address provided. Please try the reset password link again. 50 |

51 |
52 |
53 |
54 | ); 55 | } 56 | 57 | return ( 58 |
59 |
60 |
61 |

Reset Password

62 |

63 | Sending reset link to: {email} 64 |

65 |
66 | 67 | {error && ( 68 |
69 | {error} 70 | 76 |
77 | )} 78 | 79 | {success ? ( 80 |
81 | Reset link has been sent to your email address. Please check your inbox. 82 |
83 | ) : ( 84 |
85 | {isLoading ? 'Sending reset link...' : 'Processing your request...'} 86 |
87 | )} 88 |
89 |
90 | ); 91 | } 92 | 93 | export default function ResetPasswordPage() { 94 | return ( 95 | }> 96 | 97 | 98 | ); 99 | } -------------------------------------------------------------------------------- /app/update-password/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState, useEffect } from 'react'; 4 | import { useAuth } from '@/contexts/AuthContext'; 5 | import { useRouter } from 'next/navigation'; 6 | 7 | export default function UpdatePasswordPage() { 8 | const { supabase } = useAuth(); 9 | const router = useRouter(); 10 | const [newPassword, setNewPassword] = useState(''); 11 | const [confirmPassword, setConfirmPassword] = useState(''); 12 | const [error, setError] = useState(''); 13 | const [success, setSuccess] = useState(false); 14 | const [isLoading, setIsLoading] = useState(false); 15 | 16 | // Check if we have a valid hash in the URL (indicates password reset flow) 17 | useEffect(() => { 18 | const hash = window.location.hash; 19 | if (hash) { 20 | const hashParams = new URLSearchParams(hash.substring(1)); 21 | const accessToken = hashParams.get('access_token'); 22 | const refreshToken = hashParams.get('refresh_token'); 23 | const type = hashParams.get('type'); 24 | 25 | if (type === 'recovery' && accessToken) { 26 | supabase.auth.setSession({ 27 | access_token: accessToken, 28 | refresh_token: refreshToken || '', 29 | }).then(({ error }) => { 30 | if (error) { 31 | setError('Failed to set session'); 32 | } 33 | }); 34 | } else { 35 | setError('Invalid recovery link'); 36 | } 37 | } else { 38 | setError('Auth session missing!'); 39 | } 40 | }, [supabase.auth]); 41 | 42 | const handleSubmit = async (e: React.FormEvent) => { 43 | e.preventDefault(); 44 | if (newPassword !== confirmPassword) { 45 | setError('Passwords do not match'); 46 | return; 47 | } 48 | 49 | setIsLoading(true); 50 | setError(''); 51 | 52 | try { 53 | const { error } = await supabase.auth.updateUser({ 54 | password: newPassword 55 | }); 56 | 57 | if (error) throw error; 58 | 59 | setSuccess(true); 60 | // Redirect to login after successful password update 61 | setTimeout(() => { 62 | router.push('/login'); 63 | }, 2000); 64 | } catch (error) { 65 | setError(error instanceof Error ? error.message : 'Failed to update password'); 66 | } finally { 67 | setIsLoading(false); 68 | } 69 | }; 70 | 71 | return ( 72 |
73 |
74 |
75 |

Update Password

76 |

77 | Please enter your new password 78 |

79 |
80 | 81 | {error && ( 82 |
83 | {error} 84 |
85 | )} 86 | 87 | {success ? ( 88 |
89 | Password updated successfully! Redirecting to login... 90 |
91 | ) : ( 92 |
93 |
94 |
95 | 98 | setNewPassword(e.target.value)} 105 | className="appearance-none rounded-lg relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-primary focus:border-primary focus:z-10 sm:text-sm bg-white dark:bg-gray-800" 106 | placeholder="New Password" 107 | minLength={6} 108 | /> 109 |
110 |
111 | 114 | setConfirmPassword(e.target.value)} 121 | className="appearance-none rounded-lg relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-primary focus:border-primary focus:z-10 sm:text-sm bg-white dark:bg-gray-800" 122 | placeholder="Confirm Password" 123 | minLength={6} 124 | /> 125 |
126 |
127 | 134 |
135 | )} 136 |
137 |
138 | ); 139 | } -------------------------------------------------------------------------------- /app/verify-email/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect, useState, Suspense } from 'react'; 4 | import { useRouter, useSearchParams } from 'next/navigation'; 5 | import { useAuth } from '@/contexts/AuthContext'; 6 | import LoadingSpinner from '@/components/LoadingSpinner'; 7 | 8 | function VerifyEmailContent() { 9 | const { user } = useAuth(); 10 | const router = useRouter(); 11 | const searchParams = useSearchParams(); 12 | const email = searchParams.get('email'); 13 | const [countdown, setCountdown] = useState(60); 14 | 15 | // Redirect if user is already verified 16 | useEffect(() => { 17 | if (user?.email_confirmed_at) { 18 | router.replace('/dashboard'); 19 | } 20 | }, [user, router]); 21 | 22 | // Countdown timer 23 | useEffect(() => { 24 | const timer = setInterval(() => { 25 | setCountdown((prev) => (prev > 0 ? prev - 1 : 0)); 26 | }, 1000); 27 | 28 | return () => clearInterval(timer); 29 | }, []); 30 | 31 | const handleResendEmail = async () => { 32 | // Reset countdown 33 | setCountdown(60); 34 | // TODO: Implement resend verification email logic 35 | }; 36 | 37 | return ( 38 |
39 |
40 |
41 |

42 | Check Your Email 43 |

44 |

45 | We sent a verification link to{' '} 46 | {email} 47 |

48 |
49 | 50 |
51 |
52 |

Please check your email and click the verification link to continue.

53 |

54 | Didn't receive the email? You can request a new one{' '} 55 | {countdown > 0 ? ( 56 | in {countdown} seconds 57 | ) : ( 58 | 64 | )} 65 |

66 |
67 | 68 |
69 | 75 |
76 |
77 |
78 |
79 | ); 80 | } 81 | 82 | export default function VerifyEmailPage() { 83 | return ( 84 | }> 85 | 86 | 87 | ); 88 | } -------------------------------------------------------------------------------- /components/AccountManagement.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useAuth } from '@/contexts/AuthContext'; 3 | import { useRouter } from 'next/navigation'; 4 | 5 | export function AccountManagement() { 6 | const { user, signOut } = useAuth(); 7 | const router = useRouter(); 8 | const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); 9 | const [error, setError] = useState(''); 10 | const [isLoading, setIsLoading] = useState(false); 11 | 12 | // Check if user signed in with OAuth 13 | const isOAuthUser = user?.app_metadata?.provider === 'google'; 14 | 15 | const handleDeleteAccount = async () => { 16 | if (!user?.id) return; 17 | 18 | setIsLoading(true); 19 | setError(''); 20 | 21 | try { 22 | const response = await fetch(`/api/user/delete?userId=${user.id}`, { 23 | method: 'DELETE', 24 | }); 25 | 26 | if (!response.ok) { 27 | const data = await response.json(); 28 | throw new Error(data.error || 'Failed to delete account'); 29 | } 30 | 31 | await signOut(); 32 | router.push('/login'); 33 | } catch (error) { 34 | console.error('Delete account error:', error); 35 | setError(error instanceof Error ? error.message : 'Failed to delete account'); 36 | } finally { 37 | setIsLoading(false); 38 | } 39 | }; 40 | 41 | return ( 42 |
43 |

Account Management

44 | 45 | {/* User Information */} 46 |
47 |

Email: {user?.email}

48 |

Last Sign In: {new Date(user?.last_sign_in_at || '').toLocaleString()}

49 |

Account Type: {isOAuthUser ? 'Google Account' : 'Email Account'}

50 |
51 | 52 |
53 | {!isOAuthUser && ( 54 | 60 | )} 61 | 62 | {/* */} 68 |
69 | 70 | {/* Delete Account Modal */} 71 | {isDeleteModalOpen && ( 72 |
73 |
74 |

Delete Account?

75 |

76 | This action cannot be undone. All your data will be permanently deleted. 77 |

78 | {error && ( 79 |

{error}

80 | )} 81 |
82 | 88 | 95 |
96 |
97 |
98 | )} 99 |
100 | ); 101 | } -------------------------------------------------------------------------------- /components/BuyMeCoffee.tsx: -------------------------------------------------------------------------------- 1 | import { Coffee } from 'lucide-react'; 2 | import { motion } from 'framer-motion'; 3 | 4 | export function BuyMeCoffee() { 5 | const COFFEE_URL = 'https://buy.stripe.com/5kA176bA895ggog4gh'; 6 | 7 | return ( 8 | 16 | 17 | Buy Me a Coffee 18 | 19 | ); 20 | } -------------------------------------------------------------------------------- /components/DemoWidget.tsx: -------------------------------------------------------------------------------- 1 | export const DemoWidget = () => { 2 | return ( 3 |
4 |

Try It Out

5 |
6 |
7 |
8 | 11 |