From bba0affd24383c7a339fdebde1fadd908e9aadbb Mon Sep 17 00:00:00 2001 From: Aiken Tine Ahac Date: Mon, 15 Dec 2025 13:30:24 +0100 Subject: [PATCH 1/3] Custom auth UI, Stipe->Paddle migration --- .env.example | 17 +- PADDLE_SETUP.md | 319 ++++++++++++++++++ drizzle/0007_add_paddle_fields.sql | 15 + messages/en.json | 22 ++ package.json | 2 + pnpm-lock.yaml | 52 ++- public/apple-icon-dark.png | Bin 0 -> 4205 bytes public/apple-icon-light.png | Bin 0 -> 4458 bytes public/google-icon.png | Bin 0 -> 4934 bytes src/app/[locale]/(auth)/layout.tsx | 23 ++ src/app/[locale]/(auth)/sign-in/page.tsx | 220 ++++++++++++ src/app/[locale]/(auth)/sign-up/page.tsx | 232 +++++++++++++ src/app/[locale]/actions/admin.ts | 24 +- src/app/[locale]/actions/subscriptions.ts | 111 +++--- src/app/[locale]/layout.tsx | 26 +- src/app/[locale]/settings/page.tsx | 71 +++- src/app/api/webhooks/paddle/route.ts | 236 +++++++++++++ src/components/admin/create-plan-dialog.tsx | 36 +- src/components/admin/edit-plan-dialog.tsx | 32 +- .../admin/subscription-plans-table.tsx | 8 +- src/components/navigation.tsx | 136 ++++---- src/components/oauth-button.tsx | 74 ++++ src/components/paddle-provider.tsx | 46 +++ src/components/plan-selector.tsx | 21 +- src/components/sign-out-button.tsx | 27 ++ src/components/subscription-card.tsx | 21 +- src/components/ui/input-otp.tsx | 77 +++++ src/components/user-dropdown.tsx | 81 +++++ src/db/schema.ts | 5 + src/proxy.ts | 1 + 30 files changed, 1715 insertions(+), 220 deletions(-) create mode 100644 PADDLE_SETUP.md create mode 100644 drizzle/0007_add_paddle_fields.sql create mode 100644 public/apple-icon-dark.png create mode 100644 public/apple-icon-light.png create mode 100644 public/google-icon.png create mode 100644 src/app/[locale]/(auth)/layout.tsx create mode 100644 src/app/[locale]/(auth)/sign-in/page.tsx create mode 100644 src/app/[locale]/(auth)/sign-up/page.tsx create mode 100644 src/app/api/webhooks/paddle/route.ts create mode 100644 src/components/oauth-button.tsx create mode 100644 src/components/paddle-provider.tsx create mode 100644 src/components/sign-out-button.tsx create mode 100644 src/components/ui/input-otp.tsx create mode 100644 src/components/user-dropdown.tsx diff --git a/.env.example b/.env.example index 810b526..54db218 100644 --- a/.env.example +++ b/.env.example @@ -1,22 +1,25 @@ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= CLERK_SECRET_KEY= - -NEXT_PUBLIC_CLERK_SIGN_IN_FORCE_REDIRECT_URL= -NEXT_PUBLIC_CLERK_SIGN_UP_FORCE_REDIRECT_URL= +NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in +NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up +NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/library +NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/library # Database DATABASE_URL=postgres://postgres:postgres@localhost:5432/echo + # Docker Postgres POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres POSTGRES_DB=echo -# Stripe (Payment Processing) -STRIPE_SECRET_KEY=sk_test_... -NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... -STRIPE_WEBHOOK_SECRET=whsec_... +# Paddle Billing (Payment Processing) +PADDLE_API_KEY=test_... +PADDLE_WEBHOOK_SECRET=pdl_ntfset_... +NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=test_... +PADDLE_ENVIRONMENT=sandbox # Resend (Email Service) RESEND_API_KEY=re_... diff --git a/PADDLE_SETUP.md b/PADDLE_SETUP.md new file mode 100644 index 0000000..cabf2d6 --- /dev/null +++ b/PADDLE_SETUP.md @@ -0,0 +1,319 @@ +# Paddle Billing Setup Guide + +This guide will help you set up Paddle Billing for the Echo application. + +## Prerequisites + +- A Paddle account (sign up at [paddle.com](https://www.paddle.com)) +- Access to the Paddle Dashboard +- Your application deployed or running locally with ngrok for webhook testing + +--- + +## Step 1: Create Products in Paddle Dashboard + +1. **Navigate to Catalog > Products** in your Paddle Dashboard + +2. **Create your subscription products:** + +### Monthly Plan +- Click **"+ Add Product"** +- Product name: `Echo Premium Monthly` +- Description: Monthly subscription to Echo Premium features +- Click **"Continue"** + +### Add Pricing +- Click **"+ Add Price"** +- Billing cycle: `Monthly` +- Price: `€4.99` (or your desired amount) +- Click **"Save"** +- **Copy the Price ID** (format: `pri_xxx`) - you'll need this later + +### Yearly Plan +- Click **"+ Add Product"** +- Product name: `Echo Premium Yearly` +- Description: Yearly subscription to Echo Premium features +- Click **"Continue"** + +### Add Pricing +- Click **"+ Add Price"** +- Billing cycle: `Yearly` +- Price: `€49.99` (or your desired amount) +- Click **"Save"** +- **Copy the Price ID** (format: `pri_xxx`) + +### Lifetime Plan (Optional) +- Click **"+ Add Product"** +- Product name: `Echo Premium Lifetime` +- Description: Lifetime access to Echo Premium features +- Click **"Continue"** + +### Add Pricing +- Click **"+ Add Price"** +- Billing cycle: `One-time` +- Price: `€199.99` (or your desired amount) +- Click **"Save"** +- **Copy the Price ID** (format: `pri_xxx`) + +--- + +## Step 2: Update Database with Paddle Price IDs + +After creating your products and getting the Price IDs, update your database: + +```sql +-- Connect to your database and run these commands + +-- For Monthly Plan +UPDATE subscription_plans +SET paddle_price_id = 'pri_01jb...' +WHERE name = 'Premium Monthly'; + +-- For Yearly Plan +UPDATE subscription_plans +SET paddle_price_id = 'pri_01jc...' +WHERE name = 'Premium Yearly'; + +-- For Lifetime Plan (if applicable) +UPDATE subscription_plans +SET paddle_price_id = 'pri_01jd...' +WHERE name = 'Premium Lifetime'; +``` + +Replace `pri_01jb...` with your actual Paddle Price IDs. + +--- + +## Step 3: Configure Webhooks + +1. **Go to Developer Tools > Notifications** in Paddle Dashboard + +2. **Click "Add Notification Destination"** + +3. **Configure the webhook:** + - Destination Type: `Webhook` + - Webhook URL: `https://yourdomain.com/api/webhooks/paddle` + - For local testing: Use ngrok URL (e.g., `https://abc123.ngrok.io/api/webhooks/paddle`) + - Description: `Echo Production Webhooks` (or `Echo Development Webhooks`) + +4. **Select the following events:** + - ✅ `subscription.created` + - ✅ `subscription.updated` + - ✅ `subscription.canceled` + - ✅ `transaction.completed` + - ✅ `transaction.payment_failed` + +5. **Click "Save"** + +6. **Copy the Webhook Secret Key** + - Format: `pdl_ntfset_xxx` + - Add this to your `.env` file as `PADDLE_WEBHOOK_SECRET` + +--- + +## Step 4: Get API Credentials + +### Get API Key (Server-side) + +1. **Go to Developer Tools > Authentication** +2. **Click "+ Create API Key"** +3. **Configure:** + - Name: `Echo Server API Key` + - Permissions: Select all relevant permissions (at minimum: `subscription:read`, `subscription:write`, `transaction:read`, `customer:read`, `customer:write`) +4. **Click "Create"** +5. **Copy the API Key** (format: `live_xxx` for production or `test_xxx` for sandbox) +6. Add to `.env` as `PADDLE_API_KEY` + +### Get Client-side Token + +1. **Still in Developer Tools > Authentication** +2. **Look for "Client-side Tokens"** section +3. **Click "+ Create Client Token"** (or use the default one) +4. **Copy the token** (format: `test_xxx` for sandbox or `live_xxx` for production) +5. Add to `.env` as `NEXT_PUBLIC_PADDLE_CLIENT_TOKEN` + +--- + +## Step 5: Environment Configuration + +Update your `.env` file with all Paddle credentials: + +```env +# Paddle Billing (use Sandbox values for development) +PADDLE_API_KEY=test_xxx +PADDLE_WEBHOOK_SECRET=pdl_ntfset_xxx +NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=test_xxx +PADDLE_ENVIRONMENT=sandbox + +# For production, use: +# PADDLE_API_KEY=live_xxx +# PADDLE_WEBHOOK_SECRET=pdl_ntfset_xxx +# NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=live_xxx +# PADDLE_ENVIRONMENT=production +``` + +--- + +## Step 6: Database Migration + +Run the database migration to add Paddle fields: + +```bash +# Push the schema changes to your database +pnpm db:push + +# Or if you're using migrations in production +pnpm db:migrate +``` + +This will add the following columns: +- `subscription_plans.paddle_price_id` +- `subscription_plans.paddle_product_id` +- `user_subscriptions.paddle_subscription_id` +- `user_subscriptions.paddle_customer_id` +- `user_subscriptions.paddle_transaction_id` + +--- + +## Step 7: Testing with Sandbox + +### Setup ngrok for Local Testing + +1. **Install ngrok** (if not installed): + ```bash + # macOS + brew install ngrok + + # Or download from https://ngrok.com + ``` + +2. **Start your dev server:** + ```bash + pnpm dev + ``` + +3. **In a new terminal, start ngrok:** + ```bash + ngrok http 3000 + ``` + +4. **Copy the ngrok URL** (e.g., `https://abc123.ngrok.io`) + +5. **Update webhook endpoint in Paddle:** + - Go to Developer Tools > Notifications + - Edit your webhook + - Update URL to: `https://abc123.ngrok.io/api/webhooks/paddle` + +### Test Card Details (Sandbox) + +Use these test card details in Paddle Sandbox: + +- **Card Number:** `4242 4242 4242 4242` +- **Expiry:** Any future date +- **CVC:** Any 3 digits +- **Name:** Any name + +### Testing Checklist + +- [ ] Create a test subscription through your app +- [ ] Verify `subscription.created` webhook is received +- [ ] Check database - new record should appear in `user_subscriptions` +- [ ] Verify welcome email is sent +- [ ] Test cancellation flow +- [ ] Verify `subscription.canceled` webhook downgrades user to free plan +- [ ] Test payment failure scenario (use declined test card if available) + +--- + +## Step 8: Production Deployment + +### Before Going Live + +1. **Switch to Production Mode in Paddle:** + - Go to your Paddle Dashboard settings + - Enable "Go Live" mode + - Complete any required verification steps + +2. **Get Production Credentials:** + - Create new API keys for production (starting with `live_`) + - Get production client token + - Create new webhook with production URL + +3. **Update Environment Variables:** + ```env + PADDLE_API_KEY=live_xxx + PADDLE_WEBHOOK_SECRET=pdl_ntfset_xxx + NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=live_xxx + PADDLE_ENVIRONMENT=production + ``` + +4. **Update webhook endpoint:** + - Change from ngrok URL to your production URL + - Example: `https://echo.yourdomain.com/api/webhooks/paddle` + +5. **Deploy your application** with the new environment variables + +### Monitor Webhooks + +1. **Go to Developer Tools > Event Logs** in Paddle Dashboard +2. Monitor incoming webhook deliveries +3. Check for any failed webhooks and debug as needed + +--- + +## Troubleshooting + +### Webhooks Not Received + +1. **Check webhook signature verification:** + - Ensure `PADDLE_WEBHOOK_SECRET` is correct + - Check server logs for signature verification errors + +2. **Verify webhook endpoint is accessible:** + - Test the URL in a browser (should return 404 or 405 for GET requests) + - Check firewall/security rules + +3. **Check Paddle Event Logs:** + - Go to Developer Tools > Event Logs + - Look for delivery attempts and error messages + +### Invalid Price ID Errors + +1. **Verify Price IDs are correct:** + - Check they match format `pri_xxx` + - Ensure they're from the correct environment (sandbox vs production) + +2. **Check database updates:** + ```sql + SELECT name, paddle_price_id FROM subscription_plans; + ``` + +### Transaction Failures + +1. **Check API credentials:** + - Ensure `PADDLE_API_KEY` is valid and not expired + - Verify it has the correct permissions + +2. **Check environment:** + - Ensure `PADDLE_ENVIRONMENT` matches your credentials + - Don't use sandbox credentials in production mode + +--- + +## Additional Resources + +- [Paddle Documentation](https://developer.paddle.com/) +- [Paddle Webhooks Guide](https://developer.paddle.com/webhooks/overview) +- [Paddle Node.js SDK](https://github.com/PaddleHQ/paddle-node-sdk) +- [Paddle API Reference](https://developer.paddle.com/api-reference/overview) + +--- + +## Support + +If you encounter issues: + +1. Check the Paddle Dashboard Event Logs +2. Review server logs for error messages +3. Contact Paddle support through the dashboard +4. Check webhook delivery status in Paddle Dashboard diff --git a/drizzle/0007_add_paddle_fields.sql b/drizzle/0007_add_paddle_fields.sql new file mode 100644 index 0000000..3410e6f --- /dev/null +++ b/drizzle/0007_add_paddle_fields.sql @@ -0,0 +1,15 @@ +-- Add Paddle fields to subscription_plans +ALTER TABLE subscription_plans + ADD COLUMN paddle_price_id TEXT UNIQUE, + ADD COLUMN paddle_product_id TEXT; + +-- Add Paddle fields to user_subscriptions +ALTER TABLE user_subscriptions + ADD COLUMN paddle_subscription_id TEXT UNIQUE, + ADD COLUMN paddle_customer_id TEXT, + ADD COLUMN paddle_transaction_id TEXT; + +-- Add indexes for performance +CREATE INDEX subscription_plans_paddle_price_idx ON subscription_plans(paddle_price_id); +CREATE INDEX user_subscriptions_paddle_sub_idx ON user_subscriptions(paddle_subscription_id); +CREATE INDEX user_subscriptions_paddle_customer_idx ON user_subscriptions(paddle_customer_id); diff --git a/messages/en.json b/messages/en.json index 1771aa3..bf7e5be 100644 --- a/messages/en.json +++ b/messages/en.json @@ -21,6 +21,7 @@ "findUsers": "Find Users", "profile": "Profile", "signIn": "Sign In", + "signOut": "Sign Out", "premium": "Premium", "admin": "Admin", "pricing": "Pricing", @@ -212,6 +213,27 @@ "noResults": "No users found. Try a different search term.", "searchPrompt": "Enter a username or email to search for users." }, + "auth": { + "signIn": "Sign In", + "signUp": "Sign Up", + "email": "Email", + "password": "Password", + "confirmPassword": "Confirm Password", + "emailPlaceholder": "Enter your email", + "passwordPlaceholder": "Enter your password", + "continue": "Continue", + "verify": "Verify", + "verifyEmail": "Verify your email", + "enterCode": "Enter the 6-digit code sent to your email", + "noAccount": "Don't have an account?", + "haveAccount": "Already have an account?", + "signInWithGoogle": "Continue with Google", + "signInWithApple": "Continue with Apple", + "or": "or", + "invalidEmail": "Please enter a valid email", + "passwordsDontMatch": "Passwords don't match", + "allFieldsRequired": "All fields are required" + }, "toast": { "bookAdded": "Added \"{title}\" to your library", "bookStatusUpdated": "Book status updated", diff --git a/package.json b/package.json index 5a309aa..bbea1e4 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@aws-sdk/s3-request-presigner": "^3.940.0", "@clerk/backend": "^2.24.0", "@clerk/nextjs": "^6.35.5", + "@paddle/paddle-node-sdk": "^3.5.0", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", @@ -42,6 +43,7 @@ "date-fns": "^4.1.0", "dotenv": "^17.2.3", "drizzle-orm": "^0.44.7", + "input-otp": "^1.4.2", "lucide-react": "^0.555.0", "next": "16.0.5", "next-intl": "^4.5.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09d49ff..4afa145 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,10 @@ importers: version: 2.24.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@clerk/nextjs': specifier: ^6.35.5 - version: 6.35.5(next@16.0.5(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 6.35.5(next@16.0.5(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@paddle/paddle-node-sdk': + specifier: ^3.5.0 + version: 3.5.0 '@radix-ui/react-avatar': specifier: ^1.1.11 version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -85,16 +88,19 @@ importers: version: 17.2.3 drizzle-orm: specifier: ^0.44.7 - version: 0.44.7(postgres@3.4.7) + version: 0.44.7(@opentelemetry/api@1.9.0)(postgres@3.4.7) + input-otp: + specifier: ^1.4.2 + version: 1.4.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) lucide-react: specifier: ^0.555.0 version: 0.555.0(react@19.2.0) next: specifier: 16.0.5 - version: 16.0.5(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 16.0.5(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) next-intl: specifier: ^4.5.6 - version: 4.5.6(next@16.0.5(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + version: 4.5.6(next@16.0.5(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(typescript@5.9.3) postgres: specifier: ^3.4.7 version: 3.4.7 @@ -1057,6 +1063,14 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + + '@paddle/paddle-node-sdk@3.5.0': + resolution: {integrity: sha512-/Ac1N5i01/YJhiqOKlfW+Rz+nfhkaCyqpkG2P5ByeUNERNFYbAOkNc/TaXO/Nhgf9r3PoatnXH6YSpEs4KdtjA==} + engines: {node: '>=20'} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -2914,6 +2928,12 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + input-otp@1.4.2: + resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -4450,13 +4470,13 @@ snapshots: react-dom: 19.2.0(react@19.2.0) tslib: 2.8.1 - '@clerk/nextjs@6.35.5(next@16.0.5(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@clerk/nextjs@6.35.5(next@16.0.5(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@clerk/backend': 2.24.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@clerk/clerk-react': 5.57.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@clerk/shared': 3.36.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@clerk/types': 4.101.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - next: 16.0.5(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + next: 16.0.5(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react: 19.2.0 react-dom: 19.2.0(react@19.2.0) server-only: 0.0.1 @@ -4924,6 +4944,11 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@opentelemetry/api@1.9.0': + optional: true + + '@paddle/paddle-node-sdk@3.5.0': {} + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -6409,8 +6434,9 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.44.7(postgres@3.4.7): + drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(postgres@3.4.7): optionalDependencies: + '@opentelemetry/api': 1.9.0 postgres: 3.4.7 dunder-proto@1.0.1: @@ -6966,6 +6992,11 @@ snapshots: imurmurhash@0.1.4: {} + input-otp@1.4.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -7256,12 +7287,12 @@ snapshots: next-intl-swc-plugin-extractor@4.5.6: {} - next-intl@4.5.6(next@16.0.5(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(typescript@5.9.3): + next-intl@4.5.6(next@16.0.5(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(typescript@5.9.3): dependencies: '@formatjs/intl-localematcher': 0.5.10 '@swc/core': 1.15.3 negotiator: 1.0.0 - next: 16.0.5(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + next: 16.0.5(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) next-intl-swc-plugin-extractor: 4.5.6 po-parser: 1.0.2 react: 19.2.0 @@ -7271,7 +7302,7 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' - next@16.0.5(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + next@16.0.5(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: '@next/env': 16.0.5 '@swc/helpers': 0.5.15 @@ -7289,6 +7320,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.0.5 '@next/swc-win32-arm64-msvc': 16.0.5 '@next/swc-win32-x64-msvc': 16.0.5 + '@opentelemetry/api': 1.9.0 babel-plugin-react-compiler: 1.0.0 sharp: 0.34.5 transitivePeerDependencies: diff --git a/public/apple-icon-dark.png b/public/apple-icon-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..643da8e64093e52797452c1c64bc8357c753b650 GIT binary patch literal 4205 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 literal 0 HcmV?d00001 diff --git a/public/apple-icon-light.png b/public/apple-icon-light.png new file mode 100644 index 0000000000000000000000000000000000000000..a485be04ee39d8a6aa7fb19939fb50d40df20120 GIT binary patch literal 4458 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 literal 0 HcmV?d00001 diff --git a/public/google-icon.png b/public/google-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..d4d887818ef9f634543fe08d882127701773b6fd GIT binary patch literal 4934 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 +
+ {children} +
+ + ); +} diff --git a/src/app/[locale]/(auth)/sign-in/page.tsx b/src/app/[locale]/(auth)/sign-in/page.tsx new file mode 100644 index 0000000..19fc0a1 --- /dev/null +++ b/src/app/[locale]/(auth)/sign-in/page.tsx @@ -0,0 +1,220 @@ +"use client"; + +import { useState, FormEvent } from "react"; +import { useSignIn } from "@clerk/nextjs"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import Image from "next/image"; +import { Link } from "@/i18n/routing"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { OAuthButton } from "@/components/oauth-button"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from "@/components/ui/input-otp"; + +export default function SignInPage() { + const { isLoaded, signIn, setActive } = useSignIn(); + const router = useRouter(); + const t = useTranslations("auth"); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [code, setCode] = useState(""); + const [isVerifying, setIsVerifying] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const validateEmail = (email: string) => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + + if (!isLoaded || !signIn) return; + + // Validation + if (!email || !password) { + toast.error(t("allFieldsRequired")); + return; + } + + if (!validateEmail(email)) { + toast.error(t("invalidEmail")); + return; + } + + setIsLoading(true); + + try { + const signInAttempt = await signIn.create({ + identifier: email, + password, + }); + + if (signInAttempt.status === "complete") { + await setActive({ session: signInAttempt.createdSessionId }); + router.push("/library"); + } else if (signInAttempt.status === "needs_second_factor") { + // Prepare email verification + await signInAttempt.prepareSecondFactor({ strategy: "email_code" }); + setIsVerifying(true); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + console.error("Sign-in error:", err); + const errorMessage = err?.errors?.[0]?.message || "An error occurred during sign-in"; + toast.error(errorMessage); + } finally { + setIsLoading(false); + } + }; + + const handleVerify = async (e: FormEvent) => { + e.preventDefault(); + + if (!isLoaded || !signIn) return; + + if (!code || code.length !== 6) { + toast.error("Please enter the 6-digit code"); + return; + } + + setIsLoading(true); + + try { + const signInAttempt = await signIn.attemptSecondFactor({ + strategy: "email_code", + code, + }); + + if (signInAttempt.status === "complete") { + await setActive({ session: signInAttempt.createdSessionId }); + router.push("/library"); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + console.error("Verification error:", err); + const errorMessage = err?.errors?.[0]?.message || "Invalid verification code"; + toast.error(errorMessage); + } finally { + setIsLoading(false); + } + }; + + if (!isLoaded) { + return null; + } + + if (isVerifying) { + return ( +
+
+ Echo +
+ +
+
+

{t("verifyEmail")}

+

{t("enterCode")}

+
+ +
+
+ setCode(value)} + > + + + + + + + + + +
+ + +
+
+
+ ); + } + + return ( +
+
+ Echo +
+ +
+
+ + {t("signInWithGoogle")} + + + {t("signInWithApple")} + +
+ +
+
+ +
+
+ + {t("or")} + +
+
+ +
+
+ + setEmail(e.target.value)} + disabled={isLoading} + /> +
+ +
+ + setPassword(e.target.value)} + disabled={isLoading} + /> +
+ + +
+ +

+ {t("noAccount")}{" "} + + {t("signUp")} + +

+
+
+ ); +} diff --git a/src/app/[locale]/(auth)/sign-up/page.tsx b/src/app/[locale]/(auth)/sign-up/page.tsx new file mode 100644 index 0000000..df46101 --- /dev/null +++ b/src/app/[locale]/(auth)/sign-up/page.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { useState, FormEvent } from "react"; +import { useSignUp } from "@clerk/nextjs"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import Image from "next/image"; +import { Link } from "@/i18n/routing"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { OAuthButton } from "@/components/oauth-button"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from "@/components/ui/input-otp"; + +export default function SignUpPage() { + const { isLoaded, signUp, setActive } = useSignUp(); + const router = useRouter(); + const t = useTranslations("auth"); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [code, setCode] = useState(""); + const [isVerifying, setIsVerifying] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const validateEmail = (email: string) => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + + if (!isLoaded || !signUp) return; + + // Validation + if (!email || !password || !confirmPassword) { + toast.error(t("allFieldsRequired")); + return; + } + + if (!validateEmail(email)) { + toast.error(t("invalidEmail")); + return; + } + + if (password !== confirmPassword) { + toast.error(t("passwordsDontMatch")); + return; + } + + setIsLoading(true); + + try { + await signUp.create({ + emailAddress: email, + password, + }); + + // Prepare email verification + await signUp.prepareEmailAddressVerification({ strategy: "email_code" }); + setIsVerifying(true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + console.error("Sign-up error:", err); + const errorMessage = err?.errors?.[0]?.message || "An error occurred during sign-up"; + toast.error(errorMessage); + } finally { + setIsLoading(false); + } + }; + + const handleVerify = async (e: FormEvent) => { + e.preventDefault(); + + if (!isLoaded || !signUp) return; + + if (!code || code.length !== 6) { + toast.error("Please enter the 6-digit code"); + return; + } + + setIsLoading(true); + + try { + const completeSignUp = await signUp.attemptEmailAddressVerification({ + code, + }); + + if (completeSignUp.status === "complete") { + await setActive({ session: completeSignUp.createdSessionId }); + router.push("/library"); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + console.error("Verification error:", err); + const errorMessage = err?.errors?.[0]?.message || "Invalid verification code"; + toast.error(errorMessage); + } finally { + setIsLoading(false); + } + }; + + if (!isLoaded) { + return null; + } + + if (isVerifying) { + return ( +
+
+ Echo +
+ +
+
+

{t("verifyEmail")}

+

{t("enterCode")}

+
+ +
+
+ setCode(value)} + > + + + + + + + + + +
+ + +
+
+
+ ); + } + + return ( +
+
+ Echo +
+ +
+
+ + {t("signInWithGoogle")} + + + {t("signInWithApple")} + +
+ +
+
+ +
+
+ + {t("or")} + +
+
+ +
+
+ + setEmail(e.target.value)} + disabled={isLoading} + /> +
+ +
+ + setPassword(e.target.value)} + disabled={isLoading} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + disabled={isLoading} + /> +
+ + +
+ +

+ {t("haveAccount")}{" "} + + {t("signIn")} + +

+
+
+ ); +} diff --git a/src/app/[locale]/actions/admin.ts b/src/app/[locale]/actions/admin.ts index 28defbc..acf6cc6 100644 --- a/src/app/[locale]/actions/admin.ts +++ b/src/app/[locale]/actions/admin.ts @@ -263,8 +263,8 @@ export async function deleteUserAsAdmin(targetUserId: string) { */ export async function createSubscriptionPlan(data: { name: string; - stripePriceId: string | null; - stripeProductId: string | null; + paddlePriceId: string | null; + paddleProductId: string | null; price: number; interval: "month" | "year" | "lifetime" | "free"; isActive: boolean; @@ -273,14 +273,6 @@ export async function createSubscriptionPlan(data: { const currentUser = await requireRole(["admin"]); try { - // Validate Stripe IDs if provided - if (data.stripePriceId) { - const price = await stripe.prices.retrieve(data.stripePriceId); - if (!price) { - return { error: "Invalid Stripe Price ID" }; - } - } - const [plan] = await db .insert(subscriptionPlans) .values(data) @@ -310,8 +302,8 @@ export async function updateSubscriptionPlan( planId: string, data: Partial<{ name: string; - stripePriceId: string | null; - stripeProductId: string | null; + paddlePriceId: string | null; + paddleProductId: string | null; price: number; interval: "month" | "year" | "lifetime" | "free"; isActive: boolean; @@ -322,14 +314,6 @@ export async function updateSubscriptionPlan( const currentUser = await requireRole(["admin"]); try { - // Validate Stripe IDs if provided - if (data.stripePriceId) { - const price = await stripe.prices.retrieve(data.stripePriceId); - if (!price) { - return { error: "Invalid Stripe Price ID" }; - } - } - await db .update(subscriptionPlans) .set({ ...data, updatedAt: new Date() }) diff --git a/src/app/[locale]/actions/subscriptions.ts b/src/app/[locale]/actions/subscriptions.ts index 4392990..43b0047 100644 --- a/src/app/[locale]/actions/subscriptions.ts +++ b/src/app/[locale]/actions/subscriptions.ts @@ -5,16 +5,19 @@ import { db } from "@/db"; import { users, userSubscriptions, subscriptionPlans } from "@/db/schema"; import { eq, and } from "drizzle-orm"; import { revalidatePath } from "next/cache"; -import Stripe from "stripe"; +import { Paddle, Environment } from "@paddle/paddle-node-sdk"; -const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { - apiVersion: "2025-11-17.clover", +const paddle = new Paddle(process.env.PADDLE_API_KEY!, { + environment: + process.env.PADDLE_ENVIRONMENT === "production" + ? Environment.production + : Environment.sandbox, }); /** - * Create a Stripe Checkout session for subscription upgrade + * Create a Paddle Checkout transaction for subscription upgrade */ -export async function createCheckoutSession(planId: string) { +export async function createPaddleCheckout(planId: string) { const { userId } = await auth(); if (!userId) return { error: "Unauthorized" }; @@ -32,78 +35,90 @@ export async function createCheckoutSession(planId: string) { return { error: "Plan not found" }; } - if (!plan.stripePriceId) { + if (!plan.paddlePriceId) { return { error: "This plan does not require checkout" }; } - // Get or create Stripe customer ID - // Check if user has existing subscription with customer ID + // Get or create Paddle customer const existingSubscription = await db.query.userSubscriptions.findFirst({ where: eq(userSubscriptions.userId, userId), }); - let customerId = existingSubscription?.stripeCustomerId; + let customerId = existingSubscription?.paddleCustomerId; if (!customerId) { - // Create new Stripe customer (will be saved to userSubscriptions by webhook) - const customer = await stripe.customers.create({ - email: user?.email, - metadata: { userId }, - }); - customerId = customer.id; + try { + // Try to create new Paddle customer + const customer = await paddle.customers.create({ + email: user?.email ?? "", + customData: { userId }, + }); + customerId = customer.id; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (customerError: any) { + // If customer already exists, extract customer ID from error message + if (customerError.code === "customer_already_exists") { + console.log("Customer already exists, extracting ID from error"); + // Error message format: "customer email conflicts with customer of id ctm_xxx" + const match = customerError.detail?.match(/customer of id (ctm_[a-z0-9]+)/i); + if (match && match[1]) { + customerId = match[1]; + console.log("Using existing customer ID from error:", customerId); + } else { + console.error("Could not extract customer ID from error:", customerError.detail); + return { error: "Customer already exists but ID could not be determined" }; + } + } else { + throw customerError; + } + } } - // Create checkout session - const session = await stripe.checkout.sessions.create({ - customer: customerId, - mode: "subscription", - payment_method_types: ["card"], - line_items: [ - { - price: plan.stripePriceId, - quantity: 1, - }, - ], - success_url: `${process.env.NEXT_PUBLIC_BASE_URL}/subscription/success?session_id={CHECKOUT_SESSION_ID}`, - cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/subscription/canceled`, - metadata: { - userId, - planId, - }, + if (!customerId) { + return { error: "Failed to get or create customer" }; + } + + // Create checkout transaction + const transaction = await paddle.transactions.create({ + items: [{ priceId: plan.paddlePriceId, quantity: 1 }], + customerId, + customData: { userId, planId }, }); - return { sessionId: session.id, url: session.url }; + return { + transactionId: transaction.id, + checkoutUrl: transaction.checkout?.url, + }; } catch (error) { - console.error("Error creating checkout session:", error); - return { error: "Failed to create checkout session" }; + console.error("Error creating Paddle checkout:", error); + return { error: "Failed to create checkout" }; } } /** - * Create a Stripe Customer Portal session + * Cancel subscription (replaces Stripe Customer Portal) */ -export async function createPortalSession() { +export async function cancelSubscription() { const { userId } = await auth(); if (!userId) return { error: "Unauthorized" }; try { - // Get user's subscription to find Stripe customer ID const subscription = await db.query.userSubscriptions.findFirst({ where: eq(userSubscriptions.userId, userId), }); - if (!subscription?.stripeCustomerId) { - return { error: "No Stripe customer found" }; + if (!subscription?.paddleSubscriptionId) { + return { error: "No subscription found" }; } - const session = await stripe.billingPortal.sessions.create({ - customer: subscription.stripeCustomerId, - return_url: `${process.env.NEXT_PUBLIC_BASE_URL}/subscription`, + await paddle.subscriptions.cancel(subscription.paddleSubscriptionId, { + effectiveFrom: "next_billing_period", }); - return { url: session.url }; + revalidatePath("/subscription"); + return { success: true }; } catch (error) { - console.error("Error creating portal session:", error); - return { error: "Failed to create portal session" }; + console.error("Error canceling subscription:", error); + return { error: "Failed to cancel subscription" }; } } @@ -175,8 +190,8 @@ export async function upgradeToFreePlan(planId: string) { return { error: "Plan not found" }; } - // Verify it's actually a free plan (no Stripe price ID) - if (plan.stripePriceId) { + // Verify it's actually a free plan (no Paddle price ID) + if (plan.paddlePriceId) { return { error: "This plan requires payment" }; } diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 711efe2..cda3054 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -15,6 +15,7 @@ import { eq } from "drizzle-orm"; import "../globals.css"; import { Metadata } from "next"; import { assignFreePlanToUser } from "./actions/subscriptions"; +import { PaddleProvider } from "@/components/paddle-provider"; const eb_garamond = EB_Garamond({ subsets: ["latin"], @@ -156,6 +157,12 @@ export default async function LocaleLayout({ let hasUsername = true; let hasAdminAccess = false; let hasPaidPlan = false; + let userData: { + displayName: string | null; + username: string | null; + email: string; + profilePictureUrl: string | null; + } | null = null; if (userId) { // Get email from Clerk @@ -185,13 +192,23 @@ export default async function LocaleLayout({ hasUsername = !!user?.username; hasAdminAccess = user?.role === "moderator" || user?.role === "admin"; + // Prepare user data for navigation + if (user) { + userData = { + displayName: user.displayName, + username: user.username, + email: user.email, + profilePictureUrl: user.profilePictureUrl, + }; + } + // Check if user has a non-free plan (paid or lifetime) const subscription = await db.query.userSubscriptions.findFirst({ where: eq(userSubscriptions.userId, userId), with: { plan: true }, }); // User has a paid plan if they have a subscription with a non-free plan (including free lifetime plans) - hasPaidPlan = !!(subscription?.plan && (subscription.plan.price > 0 || subscription.plan.stripePriceId !== null || subscription.plan.interval === "lifetime")); + hasPaidPlan = !!(subscription?.plan && (subscription.plan.price > 0 || subscription.plan.paddlePriceId !== null || subscription.plan.interval === "lifetime")); } return ( @@ -204,7 +221,12 @@ export default async function LocaleLayout({ className={`${eb_garamond.variable} ${eb_garamond_body.variable} ${ibm_plex_mono.variable} antialiased`} > - + +
{children}