diff --git a/.env.local.example b/.env.local.example index 4944be14d..e790a3c57 100644 --- a/.env.local.example +++ b/.env.local.example @@ -3,7 +3,7 @@ NEXT_PUBLIC_SITE_URL="http://localhost:3000" # These environment variables are used for Supabase Local Dev NEXT_PUBLIC_SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" NEXT_PUBLIC_SUPABASE_URL="http://127.0.0.1:54321" -SUPABASE_SERVICE_ROLE_KEY= +SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImN6dXVlY2VkcnN1Z2l6eHVnYW9xIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTczMTQyNDI0NCwiZXhwIjoyMDQ3MDAwMjQ0fQ.IxJYmnMPpAsoPB_i-e2mghfk8jEAoqHCPsIS9wVhkb8 # Get these from Stripe dashboard NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= diff --git a/.github/workflows/sync-plans.yml b/.github/workflows/sync-plans.yml new file mode 100644 index 000000000..8d63c492d --- /dev/null +++ b/.github/workflows/sync-plans.yml @@ -0,0 +1,72 @@ +name: Sync Paystack Plans + +on: + schedule: + - cron: '0 0 * * *' # Runs daily at midnight + workflow_dispatch: # Allows manual triggering + push: + branches: + - main + paths: + - 'scripts/sync-plans.ts' + +jobs: + sync-plans: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + cache: 'npm' + + - name: Setup PNPM + uses: pnpm/action-setup@v2 + with: + version: 8 + run_install: false + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v3 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install + + - name: Create .env file + run: | + echo "NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}" >> .env + echo "SUPABASE_SERVICE_ROLE_KEY=${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}" >> .env + echo "PAYSTACK_SECRET_KEY=${{ secrets.PAYSTACK_SECRET_KEY }}" >> .env + echo "NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY=${{ secrets.NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY }}" >> .env + + - name: Verify environment variables + run: | + if [ -z "${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}" ]; then + echo "Error: NEXT_PUBLIC_SUPABASE_URL is not set" + exit 1 + fi + if [ -z "${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}" ]; then + echo "Error: SUPABASE_SERVICE_ROLE_KEY is not set" + exit 1 + fi + if [ -z "${{ secrets.PAYSTACK_SECRET_KEY }}" ]; then + echo "Error: PAYSTACK_SECRET_KEY is not set" + exit 1 + fi + + - name: Sync Paystack plans + run: pnpm paystack:sync-plans diff --git a/ImplementationGuide.md b/ImplementationGuide.md new file mode 100644 index 000000000..bbc40970d --- /dev/null +++ b/ImplementationGuide.md @@ -0,0 +1,231 @@ +# Next.js Subscription Payments with Paystack - Implementation Guide + +## 1. Initial Setup + +### 1.1 Clone and Install + +```bash +# Clone the repository +git clone https://github.com/vercel/nextjs-subscription-payments +cd nextjs-subscription-payments + +# Install dependencies +pnpm install + +# Create environment files +cp .env.example .env.local +``` + +### 1.2 Set Up Supabase + +1. Create a new project at supabase.com +2. Get your project URL and anon key +3. Update `.env.local`: + +```env +NEXT_PUBLIC_SUPABASE_URL=your-project-url +NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key +SUPABASE_SERVICE_ROLE_KEY=your-service-role-key +``` + +### 1.3 Set Up Paystack + +1. Create account at paystack.com +2. Get your test API keys +3. Add to `.env.local`: + +```env +PAYSTACK_SECRET_KEY=sk_test_xxxxx +NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY=pk_test_xxxxx +``` + +## 2. Database Setup + +### 2.1 Run Migrations + +```sql +-- Execute in Supabase SQL Editor +-- Create necessary enums +CREATE TYPE pricing_type AS ENUM ('one_time', 'recurring'); +CREATE TYPE pricing_plan_interval AS ENUM ('day', 'week', 'month', 'year'); +CREATE TYPE subscription_status AS ENUM ( + 'trialing', 'active', 'canceled', 'incomplete', + 'incomplete_expired', 'past_due', 'unpaid', 'paused' +); + +-- Create tables +CREATE TABLE products ( + id text PRIMARY KEY, + active boolean, + name text, + description text, + image text, + metadata jsonb +); + +CREATE TABLE prices ( + id text PRIMARY KEY, + product_id text REFERENCES products(id), + active boolean, + description text, + unit_amount bigint, + currency text CHECK (char_length(currency) = 3), + type pricing_type, + interval pricing_plan_interval, + interval_count integer, + trial_period_days integer, + metadata jsonb +); + +CREATE TABLE customers ( + id uuid REFERENCES auth.users PRIMARY KEY, + paystack_customer_id text +); + +CREATE TABLE subscriptions ( + id text PRIMARY KEY, + user_id uuid REFERENCES auth.users NOT NULL, + status subscription_status, + metadata jsonb, + price_id text REFERENCES prices(id), + quantity integer, + cancel_at_period_end boolean, + created timestamp with time zone DEFAULT timezone('utc'::text, now()), + current_period_start timestamp with time zone DEFAULT timezone('utc'::text, now()), + current_period_end timestamp with time zone DEFAULT timezone('utc'::text, now()), + ended_at timestamp with time zone DEFAULT timezone('utc'::text, now()), + cancel_at timestamp with time zone DEFAULT timezone('utc'::text, now()), + canceled_at timestamp with time zone DEFAULT timezone('utc'::text, now()), + trial_start timestamp with time zone DEFAULT timezone('utc'::text, now()), + trial_end timestamp with time zone DEFAULT timezone('utc'::text, now()) +); +``` + +### 2.2 Set Up Row Level Security (RLS) + +```sql +-- Enable RLS +ALTER TABLE products ENABLE ROW LEVEL SECURITY; +ALTER TABLE prices ENABLE ROW LEVEL SECURITY; +ALTER TABLE customers ENABLE ROW LEVEL SECURITY; +ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY; + +-- Create policies +CREATE POLICY "Allow public read-only access" ON products FOR SELECT USING (true); +CREATE POLICY "Allow public read-only access" ON prices FOR SELECT USING (true); +CREATE POLICY "Can only view own subscription data" ON subscriptions FOR SELECT USING (auth.uid() = user_id); +``` + +## 3. Initialize Paystack Integration + +### 3.1 Create Plans in Paystack + +1. Run the plan creation script: + +```bash +pnpm paystack:create-plans +``` + +### 3.2 Set Up Webhook + +1. Set up ngrok for local testing: + +```bash +ngrok http 3000 +``` + +2. Configure webhook URL in Paystack dashboard: + +``` +https://your-domain/api/webhooks +``` + +3. Select events to listen for: + +- subscription.create +- subscription.disable +- subscription.enable +- plan.create +- plan.update +- charge.success + +## 4. Set Up GitHub Actions + +### 4.1 Add Secrets + +Add these secrets to your GitHub repository: + +- NEXT_PUBLIC_SUPABASE_URL +- SUPABASE_SERVICE_ROLE_KEY +- PAYSTACK_SECRET_KEY +- NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY + +### 4.2 Create Workflow + +Create `.github/workflows/sync-plans.yml` for automatic plan syncing. + +## 5. Deploy + +### 5.1 Deploy to Vercel + +```bash +vercel deploy +``` + +### 5.2 Add Environment Variables + +Add all environment variables to your Vercel project. + +### 5.3 Update Webhook URL + +Update Paystack webhook URL to your production domain. + +## 6. Testing + +### 6.1 Test Subscription Flow + +1. Create a test account +2. Subscribe to a plan +3. Verify webhook receives events +4. Check database for subscription record + +### 6.2 Test Plan Sync + +1. Create a new plan in Paystack dashboard +2. Wait for webhook or trigger manual sync +3. Verify plan appears in database + +## 7. Monitoring + +### 7.1 Set Up Logging + +1. Add logging to webhook handler +2. Monitor webhook events in Vercel logs +3. Set up error notifications + +### 7.2 Regular Maintenance + +1. Run plan sync daily via GitHub Actions +2. Monitor webhook health +3. Check for failed payments +4. Verify subscription statuses + +## Common Issues and Solutions + +1. **Webhook Errors** + +- Check signature validation +- Verify environment variables +- Check Paystack webhook logs + +2. **Subscription Issues** + +- Verify customer creation +- Check payment authorization +- Validate webhook events + +3. **Database Sync Issues** + +- Check Supabase connection +- Verify RLS policies +- Monitor sync script logs diff --git a/README.md b/README.md index 8a15a212f..486d8c115 100644 --- a/README.md +++ b/README.md @@ -1,259 +1,108 @@ -# Next.js Subscription Payments Starter +# Next.js SaaS Starter for African Markets -The all-in-one starter kit for high-performance SaaS applications. +The all-in-one starter kit for building SaaS applications with local payment integrations for the African market. ## Features - Secure user management and authentication with [Supabase](https://supabase.io/docs/guides/auth) - Powerful data access & management tooling on top of PostgreSQL with [Supabase](https://supabase.io/docs/guides/database) -- Integration with [Stripe Checkout](https://stripe.com/docs/payments/checkout) and the [Stripe customer portal](https://stripe.com/docs/billing/subscriptions/customer-portal) -- Automatic syncing of pricing plans and subscription statuses via [Stripe webhooks](https://stripe.com/docs/webhooks) - -## Demo - -- https://subscription-payments.vercel.app/ - -[![Screenshot of demo](./public/demo.png)](https://subscription-payments.vercel.app/) +- Integrated payment processing with [Paystack](https://paystack.com/docs) for: + - Local card payments + - Bank transfers + - USSD payments + - Mobile money (coming soon) +- Automatic syncing of pricing plans and subscription statuses via webhooks +- Team management and role-based access control +- Dark/light mode support +- Responsive dashboard UI with [shadcn/ui](https://ui.shadcn.com) ## Architecture -![Architecture diagram](./public/architecture_diagram.png) - -## Step-by-step setup - -When deploying this template, the sequence of steps is important. Follow the steps below in order to get up and running. - -### Initiate Deployment - -#### Vercel Deploy Button - -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fnextjs-subscription-payments&env=NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,STRIPE_SECRET_KEY&envDescription=Enter%20your%20Stripe%20API%20keys.&envLink=https%3A%2F%2Fdashboard.stripe.com%2Fapikeys&project-name=nextjs-subscription-payments&repository-name=nextjs-subscription-payments&integration-ids=oac_VqOgBHqhEoFTPzGkPd7L0iH6&external-id=https%3A%2F%2Fgithub.com%2Fvercel%2Fnextjs-subscription-payments%2Ftree%2Fmain) - -The Vercel Deployment will create a new repository with this template on your GitHub account and guide you through a new Supabase project creation. The [Supabase Vercel Deploy Integration](https://vercel.com/integrations/supabase) will set up the necessary Supabase environment variables and run the [SQL migrations](./supabase/migrations/20230530034630_init.sql) to set up the Database schema on your account. You can inspect the created tables in your project's [Table editor](https://app.supabase.com/project/_/editor). - -Should the automatic setup fail, please [create a Supabase account](https://app.supabase.com/projects), and a new project if needed. In your project, navigate to the [SQL editor](https://app.supabase.com/project/_/sql) and select the "Stripe Subscriptions" starter template from the Quick start section. - -### Configure Auth - -Follow [this guide](https://supabase.com/docs/guides/auth/social-login/auth-github) to set up an OAuth app with GitHub and configure Supabase to use it as an auth provider. - -In your Supabase project, navigate to [auth > URL configuration](https://app.supabase.com/project/_/auth/url-configuration) and set your main production URL (e.g. https://your-deployment-url.vercel.app) as the site url. - -Next, in your Vercel deployment settings, add a new **Production** environment variable called `NEXT_PUBLIC_SITE_URL` and set it to the same URL. Make sure to deselect preview and development environments to make sure that preview branches and local development work correctly. - -#### [Optional] - Set up redirect wildcards for deploy previews (not needed if you installed via the Deploy Button) - -If you've deployed this template via the "Deploy to Vercel" button above, you can skip this step. The Supabase Vercel Integration will have set redirect wildcards for you. You can check this by going to your Supabase [auth settings](https://app.supabase.com/project/_/auth/url-configuration) and you should see a list of redirects under "Redirect URLs". - -Otherwise, for auth redirects (email confirmations, magic links, OAuth providers) to work correctly in deploy previews, navigate to the [auth settings](https://app.supabase.com/project/_/auth/url-configuration) and add the following wildcard URL to "Redirect URLs": `https://*-username.vercel.app/**`. You can read more about redirect wildcard patterns in the [docs](https://supabase.com/docs/guides/auth#redirect-urls-and-wildcards). - -If you've deployed this template via the "Deploy to Vercel" button above, you can skip this step. The Supabase Vercel Integration will have run database migrations for you. You can check this by going to [the Table Editor for your Supabase project](https://supabase.com/dashboard/project/_/editor), and confirming there are tables with seed data. - -Otherwise, navigate to the [SQL Editor](https://supabase.com/dashboard/project/_/sql/new), paste the contents of [the Supabase `schema.sql` file](./schema.sql), and click RUN to initialize the database. - -#### [Maybe Optional] - Set up Supabase environment variables (not needed if you installed via the Deploy Button) - -If you've deployed this template via the "Deploy to Vercel" button above, you can skip this step. The Supabase Vercel Integration will have set your environment variables for you. You can check this by going to your Vercel project settings, and clicking on 'Environment variables', there will be a list of environment variables with the Supabase icon displayed next to them. - -Otherwise navigate to the [API settings](https://app.supabase.com/project/_/settings/api) and paste them into the Vercel deployment interface. Copy project API keys and paste into the `NEXT_PUBLIC_SUPABASE_ANON_KEY` and `SUPABASE_SERVICE_ROLE_KEY` fields, and copy the project URL and paste to Vercel as `NEXT_PUBLIC_SUPABASE_URL`. - -Congrats, this completes the Supabase setup, almost there! - -### Configure Stripe - -Next, we'll need to configure [Stripe](https://stripe.com/) to handle test payments. If you don't already have a Stripe account, create one now. - -For the following steps, make sure you have the ["Test Mode" toggle](https://stripe.com/docs/testing) switched on. +The template uses the following technology stack: +- [Next.js 14](https://nextjs.org) +- [Auth.js](https://authjs.dev) +- [Supabase](https://supabase.io) +- [Paystack](https://paystack.com) +- [Vercel](https://vercel.com) -#### Create a Webhook - -We need to create a webhook in the `Developers` section of Stripe. Pictured in the architecture diagram above, this webhook is the piece that connects Stripe to your Vercel Serverless Functions. - -1. Click the "Add Endpoint" button on the [test Endpoints page](https://dashboard.stripe.com/test/webhooks). -1. Enter your production deployment URL followed by `/api/webhooks` for the endpoint URL. (e.g. `https://your-deployment-url.vercel.app/api/webhooks`) -1. Click `Select events` under the `Select events to listen to` heading. -1. Click `Select all events` in the `Select events to send` section. -1. Copy `Signing secret` as we'll need that in the next step (e.g `whsec_xxx`) (/!\ be careful not to copy the webook id we_xxxx). -1. In addition to the `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` and the `STRIPE_SECRET_KEY` we've set earlier during deployment, we need to add the webhook secret as `STRIPE_WEBHOOK_SECRET` env var. - -#### Redeploy with new env vars - -For the newly set environment variables to take effect and everything to work together correctly, we need to redeploy our app in Vercel. In your Vercel Dashboard, navigate to deployments, click the overflow menu button and select "Redeploy" (do NOT enable the "Use existing Build Cache" option). Once Vercel has rebuilt and redeployed your app, you're ready to set up your products and prices. - -#### Create product and pricing information - -Your application's webhook listens for product updates on Stripe and automatically propagates them to your Supabase database. So with your webhook listener running, you can now create your product and pricing information in the [Stripe Dashboard](https://dashboard.stripe.com/test/products). - -Stripe Checkout currently supports pricing that bills a predefined amount at a specific interval. More complex plans (e.g., different pricing tiers or seats) are not yet supported. - -For example, you can create business models with different pricing tiers, e.g.: - -- Product 1: Hobby - - Price 1: 10 USD per month - - Price 2: 100 USD per year -- Product 2: Freelancer - - Price 1: 20 USD per month - - Price 2: 200 USD per year - -Optionally, to speed up the setup, we have added a [fixtures file](fixtures/stripe-fixtures.json) to bootstrap test product and pricing data in your Stripe account. The [Stripe CLI](https://stripe.com/docs/stripe-cli#install) `fixtures` command executes a series of API requests defined in this JSON file. Simply run `stripe fixtures fixtures/stripe-fixtures.json`. - -**Important:** Make sure that you've configured your Stripe webhook correctly and redeployed with all needed environment variables. - -#### Configure the Stripe customer portal - -1. Set your custom branding in the [settings](https://dashboard.stripe.com/settings/branding) -1. Configure the Customer Portal [settings](https://dashboard.stripe.com/test/settings/billing/portal) -1. Toggle on "Allow customers to update their payment methods" -1. Toggle on "Allow customers to update subscriptions" -1. Toggle on "Allow customers to cancel subscriptions" -1. Add the products and prices that you want -1. Set up the required business information and links - -### That's it - -I know, that was quite a lot to get through, but it's worth it. You're now ready to earn recurring revenue from your customers. 🥳 - -## Develop locally - -If you haven't already done so, clone your Github repository to your local machine. - -### Install dependencies - -Ensure you have [pnpm](https://pnpm.io/installation) installed and run: +## Setup +### 1. Clone and Install ```bash +git clone https://github.com/yourusername/your-repo +cd your-repo pnpm install ``` -Next, use the [Vercel CLI](https://vercel.com/docs/cli) to link your project: +### 2. Configure Supabase +1. Create a project at [Supabase](https://app.supabase.com) +2. Run database migrations +3. Set up auth providers +4. Copy environment variables -```bash -pnpm dlx vercel login -pnpm dlx vercel link -``` - -`pnpm dlx` runs a package from the registry, without installing it as a dependency. Alternatively, you can install these packages globally, and drop the `pnpm dlx` part. - -If you don't intend to use a local Supabase instance for development and testing, you can use the Vercel CLI to download the development env vars: +### 3. Configure Paystack +1. Create a [Paystack account](https://paystack.com) +2. Get your API keys +3. Set up webhook endpoint at `your-domain.com/api/webhooks` +4. Configure payment methods +### 4. Environment Variables +Create a `.env.local` file: ```bash -pnpm dlx vercel env pull .env.local -``` - -Running this command will create a new `.env.local` file in your project folder. For security purposes, you will need to set the `SUPABASE_SERVICE_ROLE_KEY` manually from your [Supabase dashboard](https://app.supabase.io/) (`Settings > API`). If you are not using a local Supabase instance, you should also change the `--local` flag to `--linked' or '--project-id ' in the `supabase:generate-types` script in `package.json`.(see -> [https://supabase.com/docs/reference/cli/supabase-gen-types-typescript]) - -### Local development with Supabase +# Supabase +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= +SUPABASE_SERVICE_ROLE_KEY= -It's highly recommended to use a local Supabase instance for development and testing. We have provided a set of custom commands for this in `package.json`. +# Paystack +NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY= +PAYSTACK_SECRET_KEY= -First, you will need to install [Docker](https://www.docker.com/get-started/). You should also copy or rename: - -- `.env.local.example` -> `.env.local` -- `.env.example` -> `.env` - -Next, run the following command to start a local Supabase instance and run the migrations to set up the database schema: - -```bash -pnpm supabase:start -``` - -The terminal output will provide you with URLs to access the different services within the Supabase stack. The Supabase Studio is where you can make changes to your local database instance. - -Copy the value for the `service_role_key` and paste it as the value for the `SUPABASE_SERVICE_ROLE_KEY` in your `.env.local` file. - -You can print out these URLs at any time with the following command: - -```bash -pnpm supabase:status -``` - -To link your local Supabase instance to your project, run the following command, navigate to the Supabase project you created above, and enter your database password. - -```bash -pnpm supabase:link -``` - -If you need to reset your database password, head over to [your database settings](https://supabase.com/dashboard/project/_/settings/database) and click "Reset database password", and this time copy it across to a password manager! 😄 - -🚧 Warning: This links our Local Development instance to the project we are using for `production`. Currently, it only has test records, but once it has customer data, we recommend using [Branching](https://supabase.com/docs/guides/platform/branching) or manually creating a separate `preview` or `staging` environment, to ensure your customer's data is not used locally, and schema changes/migrations can be thoroughly tested before shipping to `production`. - -Once you've linked your project, you can pull down any schema changes you made in your remote database with: - -```bash -pnpm supabase:pull +# General +NEXT_PUBLIC_SITE_URL= ``` -You can seed your local database with any data you added in your remote database with: - -```bash -pnpm supabase:generate-seed -pnpm supabase:reset -``` - -🚧 Warning: this is seeding data from the `production` database. Currently, this only contains test data, but we recommend using [Branching](https://supabase.com/docs/guides/platform/branching) or manually setting up a `preview` or `staging` environment once this contains real customer data. - -You can make changes to the database schema in your local Supabase Studio and run the following command to generate TypeScript types to match your schema: - -```bash -pnpm supabase:generate-types -``` - -You can also automatically generate a migration file with all the changes you've made to your local database schema with the following command: - -```bash -pnpm supabase:generate-migration -``` - -And push those changes to your remote database with: - -```bash -pnpm supabase:push -``` - -Remember to test your changes thoroughly in your `local` and `staging` or `preview` environments before deploying them to `production`! - -### Use the Stripe CLI to test webhooks - -Use the [Stripe CLI](https://stripe.com/docs/stripe-cli) to [login to your Stripe account](https://stripe.com/docs/stripe-cli#login-account): - -```bash -pnpm stripe:login -``` - -This will print a URL to navigate to in your browser and provide access to your Stripe account. - -Next, start local webhook forwarding: - -```bash -pnpm stripe:listen -``` - -Running this Stripe command will print a webhook secret (such as, `whsec_***`) to the console. Set `STRIPE_WEBHOOK_SECRET` to this value in your `.env.local` file. If you haven't already, you should also set `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` and `STRIPE_SECRET_KEY` in your `.env.local` file using the **test mode**(!) keys from your Stripe dashboard. - -### Run the Next.js client - -In a separate terminal, run the following command to start the development server: - +### 5. Run Locally ```bash pnpm dev ``` -Note that webhook forwarding and the development server must be running concurrently in two separate terminals for the application to work correctly. +## Payment Flow -Finally, navigate to [http://localhost:3000](http://localhost:3000) in your browser to see the application rendered. +The template handles payments through Paystack: +1. User selects a plan +2. Redirected to Paystack checkout +3. Payment processing through: + - Local credit/debit cards + - Bank transfers + - USSD + - Mobile money (coming soon) +4. Webhook handles subscription events +5. Customer portal for subscription management -## Going live +## Webhooks -### Archive testing products +Paystack webhooks handle: +- Subscription creation +- Subscription updates +- Payment success/failure +- Subscription cancellation -Archive all test mode Stripe products before going live. Before creating your live mode products, make sure to follow the steps below to set up your live mode env vars and webhooks. +## Contributing -### Configure production environment variables +Contributions welcome! Please: +1. Fork the repository +2. Create your feature branch +3. Commit your changes +4. Push to the branch +5. Create a PR -To run the project in live mode and process payments with Stripe, switch Stripe from "test mode" to "production mode." Your Stripe API keys will be different in production mode, and you will have to create a separate production mode webhook. Copy these values and paste them into Vercel, replacing the test mode values. +## Support -### Redeploy +For questions, features, or support: +- Email: your.email@example.com +- Twitter: @yourhandle -Afterward, you will need to rebuild your production deployment for the changes to take effect. Within your project Dashboard, navigate to the "Deployments" tab, select the most recent deployment, click the overflow menu button (next to the "Visit" button) and select "Redeploy" (do NOT enable the "Use existing Build Cache" option). +## License -To verify you are running in production mode, test checking out with the [Stripe test card](https://stripe.com/docs/testing). The test card should not work. +MIT License \ No newline at end of file diff --git a/app/account/manage-subscription/page.tsx b/app/account/manage-subscription/page.tsx new file mode 100644 index 000000000..5eb56dfc9 --- /dev/null +++ b/app/account/manage-subscription/page.tsx @@ -0,0 +1,91 @@ +// app/account/manage-subscription/page.tsx +import { cookies } from 'next/headers'; +import { redirect } from 'next/navigation'; +import { createServerComponentClient } from '@supabase/auth-helpers-nextjs'; +import Card from '@/components/ui/Card'; +import { Database } from '@/types_db'; +import ManageSubscriptionForm from '@/components/ui/AccountForms/ManageSubscriptionForm'; + +export const dynamic = 'force-dynamic'; +export const revalidate = 0; + +export default async function ManageSubscription() { + const supabase = createServerComponentClient({ + cookies + }); + + const { + data: { user }, + error: userError + } = await supabase.auth.getUser(); + + if (userError || !user) { + return redirect('/signin'); + } + + const { data: subscription, error: subscriptionError } = await supabase + .from('subscriptions') + .select('*, prices(*, products(*))') + .eq('user_id', user.id) + .in('status', ['active', 'trialing']) + .maybeSingle(); + + if (subscriptionError) { + console.error(subscriptionError); + } + + if (!subscription) { + return redirect('/pricing'); + } + + return ( +
+
+
+

+ Manage Subscription +

+

+ Update your subscription preferences below. +

+
+
+ + + Next billing date:{' '} + {new Date( + subscription.current_period_end + ).toLocaleDateString()} + + + {new Intl.NumberFormat('en-US', { + style: 'currency', + currency: subscription.prices?.currency || 'USD' + }).format((subscription.prices?.unit_amount || 0) / 100)} + /{subscription.prices?.interval} + +
+ } + > +
+ +
+ + + +
+

Coming soon...

+
+
+
+ +
+ ); +} diff --git a/app/account/page.tsx b/app/account/page.tsx index 0a2e9cf81..26113e452 100644 --- a/app/account/page.tsx +++ b/app/account/page.tsx @@ -1,26 +1,45 @@ -import CustomerPortalForm from '@/components/ui/AccountForms/CustomerPortalForm'; -import EmailForm from '@/components/ui/AccountForms/EmailForm'; -import NameForm from '@/components/ui/AccountForms/NameForm'; +// app/account/page.tsx +import { createServerComponentClient } from '@supabase/auth-helpers-nextjs'; +import { cookies } from 'next/headers'; import { redirect } from 'next/navigation'; -import { createClient } from '@/utils/supabase/server'; -import { - getUserDetails, - getSubscription, - getUser -} from '@/utils/supabase/queries'; +import Card from '@/components/ui/Card'; +import { Database } from '@/types_db'; +import AccountForm from '@/components/ui/AccountForms/AccountForm'; + +export const dynamic = 'force-dynamic'; +export const revalidate = 0; export default async function Account() { - const supabase = createClient(); - const [user, userDetails, subscription] = await Promise.all([ - getUser(supabase), - getUserDetails(supabase), - getSubscription(supabase) - ]); - - if (!user) { + const supabase = createServerComponentClient({ cookies }); + + const { + data: { user }, + error: userError + } = await supabase.auth.getUser(); + + if (userError || !user) { return redirect('/signin'); } + // Get user details from Supabase + const { data: userData } = await supabase + .from('users') + .select('*') + .eq('id', user.id) + .single(); + + // Get active subscription + const { data: subscription, error: subscriptionError } = await supabase + .from('subscriptions') + .select('*, prices(*, products(*))') + .eq('user_id', user.id) + .in('status', ['active', 'trialing']) + .maybeSingle(); + + if (subscriptionError) { + console.error('Error fetching subscription:', subscriptionError); + } + return (
@@ -29,14 +48,87 @@ export default async function Account() { Account

- We partnered with Stripe for a simplified billing. + Manage your account and subscription here.

- -
- - - +
+ {/* Account Settings */} + +
+ {userData && ( + + )} +
+
+ + {/* Subscription Info */} + {subscription ? ( + + For billing issues, please contact support. +
+ } + > +
+
+ Plan + + {subscription.prices?.products?.name} + +
+
+ Price + + {new Intl.NumberFormat('en-US', { + style: 'currency', + currency: subscription.prices?.currency || 'USD' + }).format((subscription.prices?.unit_amount || 0) / 100)} + /{subscription.prices?.interval} + +
+
+ Status + + {subscription.status} + +
+
+ Next billing date + + {new Date( + subscription.current_period_end + ).toLocaleDateString()} + +
+
+ + ) : ( + + View our pricing plans to subscribe. +
+ } + > +
+ + View Pricing Plans → + +
+ + )} +
); diff --git a/app/actions/paystack.ts b/app/actions/paystack.ts new file mode 100644 index 000000000..5170b8607 --- /dev/null +++ b/app/actions/paystack.ts @@ -0,0 +1,137 @@ +// app/actions/paystack.ts +'use server'; + +import { createAdminClient } from '@/utils/supabase/admin'; +import { createClient } from '@/utils/supabase/server'; +import { getURL, getErrorRedirect } from '@/utils/helpers'; +import { Tables } from '@/types_db'; +import { randomUUID } from 'crypto'; + +type Price = Tables<'prices'>; + +type CheckoutResponse = { + errorRedirect?: string; + authorizationUrl?: string; +}; + +export async function checkoutWithPaystack( + price: Price, + redirectPath: string = '/account' +): Promise { + try { + // Get the user from Supabase auth + const supabase = createClient(); + const { + error, + data: { user } + } = await supabase.auth.getUser(); + + if (error || !user) { + console.error(error); + throw new Error('Could not get user session.'); + } + + // Create a reference ID for this transaction + const reference = `sub_${randomUUID()}`; + + // Use admin client for customer operations + const adminClient = createAdminClient(); + + // Check if customer exists + const { data: customerData, error: customerError } = await adminClient + .from('customers') + .select('paystack_customer_id') + .eq('id', user.id) + .single(); + + if (customerError && customerError.code !== 'PGRST116') { + console.error('Error fetching customer:', customerError); + throw new Error('Failed to check customer record.'); + } + + // If no customer exists, create one + if (!customerData) { + const { error: insertError } = await adminClient + .from('customers') + .insert([ + { + id: user.id, + paystack_customer_id: null // Will be updated by webhook + } + ]); + + if (insertError) { + console.error('Error creating customer:', insertError); + throw new Error('Failed to create customer record.'); + } + } + + // Initialize transaction with Paystack + try { + const response = await fetch( + 'https://api.paystack.co/transaction/initialize', + { + method: 'POST', + headers: { + Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + email: user.email, + amount: price.unit_amount, // Amount should be in kobo/cents + plan: price.id, // This is your plan code in Paystack + callback_url: getURL(`${redirectPath}?reference=${reference}`), + metadata: { + user_id: user.id, + price_id: price.id, + reference, + custom_fields: [ + { + display_name: 'User ID', + variable_name: 'user_id', + value: user.id + }, + { + display_name: 'Price ID', + variable_name: 'price_id', + value: price.id + } + ] + } + }) + } + ); + + const data = await response.json(); + console.log('Paystack initialization response:', data); + + if (!data.status) { + throw new Error(data.message); + } + + return { authorizationUrl: data.data.authorization_url }; + } catch (err) { + console.error('Paystack initialization error:', err); + throw new Error('Unable to initialize payment.'); + } + } catch (error) { + console.error('Checkout error:', error); + if (error instanceof Error) { + return { + errorRedirect: getErrorRedirect( + redirectPath, + error.message, + 'Please try again later or contact a system administrator.' + ) + }; + } else { + return { + errorRedirect: getErrorRedirect( + redirectPath, + 'An unknown error occurred.', + 'Please try again later or contact a system administrator.' + ) + }; + } + } +} diff --git a/app/api/account/update/route.ts b/app/api/account/update/route.ts new file mode 100644 index 000000000..00c082f0d --- /dev/null +++ b/app/api/account/update/route.ts @@ -0,0 +1,58 @@ +// app/api/account/update/route.ts +import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; +import { cookies } from 'next/headers'; +import { NextResponse } from 'next/server'; +import { Database } from '@/types_db'; + +export const dynamic = 'force-dynamic'; + +export async function POST(req: Request) { + try { + const { fullName, email } = await req.json(); + const supabase = createRouteHandlerClient({ cookies }); + + const { + data: { user }, + error: userError + } = await supabase.auth.getUser(); + + if (userError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Update auth email if changed + if (email !== user.email) { + const { error: updateAuthError } = await supabase.auth.updateUser({ + email: email + }); + + if (updateAuthError) { + return NextResponse.json( + { error: updateAuthError.message }, + { status: 400 } + ); + } + } + + // Update user profile + const { error: updateError } = await supabase + .from('users') + .update({ + full_name: fullName, + updated_at: new Date().toISOString() + }) + .eq('id', user.id); + + if (updateError) { + return NextResponse.json({ error: updateError.message }, { status: 400 }); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error updating account:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/app/api/checkout/callback/route.ts b/app/api/checkout/callback/route.ts new file mode 100644 index 000000000..661ba013b --- /dev/null +++ b/app/api/checkout/callback/route.ts @@ -0,0 +1,171 @@ +// app/api/checkout/callback/route.ts +import { NextResponse } from 'next/server'; +import { createAdminClient } from '@/utils/supabase/admin'; +import { headers } from 'next/headers'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: Request) { + try { + const headersList = headers(); + const domain = headersList.get('host') || ''; + const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; + const baseUrl = `${protocol}://${domain}`; + + // Get query parameters + const { searchParams } = new URL(request.url); + const reference = searchParams.get('reference'); + const trxref = searchParams.get('trxref'); + + if (!reference) { + console.error('Missing reference'); + return NextResponse.redirect( + new URL('/pricing?error=missing_reference', baseUrl) + ); + } + + console.log('Verifying transaction:', reference); + + // Verify transaction with Paystack + const verifyResponse = await fetch( + `https://api.paystack.co/transaction/verify/${reference}`, + { + headers: { + Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`, + 'Content-Type': 'application/json' + } + } + ); + + const verifyData = await verifyResponse.json(); + console.log('Verification response:', JSON.stringify(verifyData, null, 2)); + + if (!verifyData.status || verifyData.data?.status !== 'success') { + console.error('Payment verification failed:', verifyData); + return NextResponse.redirect( + new URL( + `/pricing?error=payment_failed&message=${verifyData.message}`, + baseUrl + ) + ); + } + + // Get metadata from transaction + const { metadata } = verifyData.data; + console.log('Transaction metadata:', metadata); + + if (!metadata?.user_id || !metadata?.price_id) { + console.error('Missing metadata:', metadata); + return NextResponse.redirect( + new URL('/pricing?error=missing_metadata', baseUrl) + ); + } + + const supabase = createAdminClient(); + + // Update customer record + const { error: customerError } = await supabase.from('customers').upsert({ + id: metadata.user_id, + paystack_customer_id: verifyData.data.customer.customer_code + }); + + if (customerError) { + console.error('Error updating customer:', customerError); + throw new Error('Failed to update customer record'); + } + + // Create subscription record + const subscriptionData = { + id: reference, + user_id: metadata.user_id, + status: 'active' as const, + price_id: metadata.price_id, + quantity: 1, + cancel_at_period_end: false, + created: new Date().toISOString(), + current_period_start: new Date().toISOString(), + // Set period end to 30 days from now for monthly, 365 for yearly + current_period_end: new Date( + Date.now() + + (verifyData.data.plan?.interval === 'annually' ? 365 : 30) * + 24 * + 60 * + 60 * + 1000 + ).toISOString(), + metadata: { + paystack_reference: reference, + paystack_customer_code: verifyData.data.customer.customer_code, + paystack_plan_code: verifyData.data.plan?.plan_code, + paystack_subscription_code: + verifyData.data.authorization?.authorization_code + } + }; + + const { error: subscriptionError } = await supabase + .from('subscriptions') + .upsert([subscriptionData]); + + if (subscriptionError) { + console.error('Error creating subscription:', subscriptionError); + throw new Error('Failed to create subscription record'); + } + + // Create subscription in Paystack + if (verifyData.data.plan) { + try { + const subscriptionResponse = await fetch( + 'https://api.paystack.co/subscription', + { + method: 'POST', + headers: { + Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + customer: verifyData.data.customer.customer_code, + plan: verifyData.data.plan.plan_code, + authorization: verifyData.data.authorization.authorization_code + }) + } + ); + + const subscriptionResult = await subscriptionResponse.json(); + console.log('Paystack subscription created:', subscriptionResult); + + // Update subscription with Paystack subscription code + if (subscriptionResult.status) { + await supabase + .from('subscriptions') + .update({ + metadata: { + ...subscriptionData.metadata, + paystack_subscription_code: + subscriptionResult.data.subscription_code + } + }) + .eq('id', reference); + } + } catch (error) { + console.error('Error creating Paystack subscription:', error); + // Continue anyway as payment was successful + } + } + + // Redirect to success page + return NextResponse.redirect(new URL('/account', baseUrl)); + } catch (error) { + console.error('Callback error:', error); + const headersList = headers(); + const domain = headersList.get('host') || ''; + const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; + const baseUrl = `${protocol}://${domain}`; + + return NextResponse.redirect( + new URL( + '/pricing?error=server_error&message=Something+went+wrong', + baseUrl + ) + ); + } +} diff --git a/app/api/subscription/cancel/route.ts b/app/api/subscription/cancel/route.ts new file mode 100644 index 000000000..da492d583 --- /dev/null +++ b/app/api/subscription/cancel/route.ts @@ -0,0 +1,109 @@ +// app/api/subscription/cancel/route.ts +import { cookies } from 'next/headers'; +import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; +import { Database } from '@/types_db'; +import { NextResponse } from 'next/server'; + +export async function POST(req: Request) { + try { + const { subscriptionId } = await req.json(); + + if (!subscriptionId) { + return new NextResponse( + JSON.stringify({ + error: 'subscriptionId is required' + }), + { status: 400 } + ); + } + + const supabase = createRouteHandlerClient({ cookies }); + + // Get current user + const { + data: { user }, + error: userError + } = await supabase.auth.getUser(); + + if (userError || !user) { + return new NextResponse( + JSON.stringify({ + error: 'Unauthorized' + }), + { status: 401 } + ); + } + + // Get subscription details + const { data: subscription, error: subError } = await supabase + .from('subscriptions') + .select('*, prices(*)') + .eq('id', subscriptionId) + .eq('user_id', user.id) + .single(); + + if (subError || !subscription) { + return new NextResponse( + JSON.stringify({ + error: 'Subscription not found' + }), + { status: 404 } + ); + } + + // Make request to Paystack to cancel subscription + const response = await fetch( + `https://api.paystack.co/subscription/disable`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + code: subscription.id, + token: (subscription.metadata as { email_token?: string }) + ?.email_token // This should be stored in metadata during subscription creation + }) + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to cancel subscription'); + } + + // Update subscription status in database + const { error: updateError } = await supabase + .from('subscriptions') + .update({ + status: 'canceled', + cancel_at_period_end: true, + canceled_at: new Date().toISOString(), + metadata: { + ...(subscription.metadata as Record), + paystack_status: 'canceled' + } + }) + .eq('id', subscriptionId); + + if (updateError) { + throw updateError; + } + + return new NextResponse( + JSON.stringify({ + message: 'Subscription cancelled successfully' + }), + { status: 200 } + ); + } catch (error) { + console.error('Error cancelling subscription:', error); + return new NextResponse( + JSON.stringify({ + error: error instanceof Error ? error.message : 'Unknown error occurred' + }), + { status: 500 } + ); + } +} diff --git a/app/api/sync-plans/route.ts b/app/api/sync-plans/route.ts new file mode 100644 index 000000000..22b7c5554 --- /dev/null +++ b/app/api/sync-plans/route.ts @@ -0,0 +1,80 @@ +// app/api/sync-plans/route.ts +import { paystack } from '@/utils/paystack/config'; +import { createClient } from '@/utils/supabase/server'; +import { NextResponse } from 'next/server'; + +export async function POST(req: Request) { + try { + // Verify secret token to prevent unauthorized access + const authHeader = req.headers.get('authorization'); + if (authHeader !== `Bearer ${process.env.SYNC_API_SECRET}`) { + return new Response('Unauthorized', { status: 401 }); + } + + const supabase = createClient(); + + // Fetch all plans from Paystack + const { data: plans } = await paystack.listPlans({}); + console.log('Fetched plans from Paystack:', plans); + + for (const plan of plans.data) { + // Create or update product + const { data: product, error: productError } = await supabase + .from('products') + .upsert({ + id: plan.plan_code, + active: true, + name: plan.name, + description: plan.description, + metadata: { + paystack_id: plan.id, + features: plan.description?.split('\n') || [] + } + }) + .select() + .single(); + + if (productError) { + console.error('Error upserting product:', productError); + continue; + } + + // Create or update price + const { error: priceError } = await supabase.from('prices').upsert({ + id: plan.plan_code, + product_id: plan.plan_code, + active: true, + currency: plan.currency, + type: 'recurring', + unit_amount: plan.amount, + interval: + plan.interval === 'monthly' + ? 'month' + : plan.interval === 'annually' + ? 'year' + : plan.interval === 'weekly' + ? 'week' + : 'month', + interval_count: 1, + metadata: { + paystack_id: plan.id + } + }); + + if (priceError) { + console.error('Error upserting price:', priceError); + } + } + + return NextResponse.json({ + success: true, + message: `Synced ${plans.data.length} plans` + }); + } catch (error) { + console.error('Error syncing plans:', error); + return NextResponse.json( + { success: false, error: 'Failed to sync plans' }, + { status: 500 } + ); + } +} diff --git a/app/api/webhooks/route.ts b/app/api/webhooks/route.ts index 8371d42e4..b33fa5617 100644 --- a/app/api/webhooks/route.ts +++ b/app/api/webhooks/route.ts @@ -1,96 +1,238 @@ -import Stripe from 'stripe'; -import { stripe } from '@/utils/stripe/config'; -import { - upsertProductRecord, - upsertPriceRecord, - manageSubscriptionStatusChange, - deleteProductRecord, - deletePriceRecord -} from '@/utils/supabase/admin'; +// app/api/webhooks/route.ts +import { createAdminClient } from '@/utils/supabase/admin'; +import { Database } from '@/types_db'; +import crypto from 'crypto'; + +function validatePaystackWebhook( + requestBody: string, + paystackSignature: string +): boolean { + try { + if (!process.env.PAYSTACK_SECRET_KEY) { + console.error('PAYSTACK_SECRET_KEY is not set'); + return false; + } + + const hash = crypto + .createHmac('sha512', process.env.PAYSTACK_SECRET_KEY) + .update(requestBody) + .digest('hex'); + + return hash === paystackSignature; + } catch (error) { + console.error('Error validating webhook signature:', error); + return false; + } +} const relevantEvents = new Set([ - 'product.created', - 'product.updated', - 'product.deleted', - 'price.created', - 'price.updated', - 'price.deleted', - 'checkout.session.completed', - 'customer.subscription.created', - 'customer.subscription.updated', - 'customer.subscription.deleted' + 'subscription.create', + 'subscription.disable', + 'subscription.enable', + 'charge.success', + 'transfer.success', + 'transfer.failed', + 'customer.created' ]); export async function POST(req: Request) { const body = await req.text(); - const sig = req.headers.get('stripe-signature') as string; - const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; - let event: Stripe.Event; + const signature = req.headers.get('x-paystack-signature') as string; try { - if (!sig || !webhookSecret) - return new Response('Webhook secret not found.', { status: 400 }); - event = stripe.webhooks.constructEvent(body, sig, webhookSecret); - console.log(`🔔 Webhook received: ${event.type}`); - } catch (err: any) { - console.log(`❌ Error message: ${err.message}`); - return new Response(`Webhook Error: ${err.message}`, { status: 400 }); - } + if (!signature) { + console.error('No Paystack signature found'); + return new Response('No signature found.', { status: 400 }); + } + + if (!validatePaystackWebhook(body, signature)) { + console.error('Invalid Paystack signature'); + return new Response('Invalid signature.', { status: 400 }); + } + + const event = JSON.parse(body); + console.log(`🔔 Webhook received: ${event.event}`); + console.log('Event data:', JSON.stringify(event.data, null, 2)); - if (relevantEvents.has(event.type)) { - try { - switch (event.type) { - case 'product.created': - case 'product.updated': - await upsertProductRecord(event.data.object as Stripe.Product); - break; - case 'price.created': - case 'price.updated': - await upsertPriceRecord(event.data.object as Stripe.Price); - break; - case 'price.deleted': - await deletePriceRecord(event.data.object as Stripe.Price); - break; - case 'product.deleted': - await deleteProductRecord(event.data.object as Stripe.Product); - break; - case 'customer.subscription.created': - case 'customer.subscription.updated': - case 'customer.subscription.deleted': - const subscription = event.data.object as Stripe.Subscription; - await manageSubscriptionStatusChange( - subscription.id, - subscription.customer as string, - event.type === 'customer.subscription.created' - ); - break; - case 'checkout.session.completed': - const checkoutSession = event.data.object as Stripe.Checkout.Session; - if (checkoutSession.mode === 'subscription') { - const subscriptionId = checkoutSession.subscription; - await manageSubscriptionStatusChange( - subscriptionId as string, - checkoutSession.customer as string, - true + // Use admin client instead of route handler client + const supabase = createAdminClient(); + + if (relevantEvents.has(event.event)) { + try { + switch (event.event) { + case 'customer.created': + console.log( + 'Creating/updating customer:', + event.data.customer_code ); - } - break; - default: - throw new Error('Unhandled relevant event!'); - } - } catch (error) { - console.log(error); - return new Response( - 'Webhook handler failed. View your Next.js function logs.', - { - status: 400 + // Upsert customer record + const { error: customerError } = await supabase + .from('customers') + .upsert({ + id: event.data.metadata.user_id, + paystack_customer_id: event.data.customer_code + }); + + if (customerError) { + throw new Error( + `Customer upsert failed: ${customerError.message}` + ); + } + break; + + case 'subscription.create': + console.log('Creating subscription:', event.data.subscription_code); + + // First ensure customer exists + const { data: existingCustomer, error: customerLookupError } = + await supabase + .from('customers') + .select('id') + .eq('paystack_customer_id', event.data.customer.customer_code) + .single(); + + if ( + customerLookupError && + customerLookupError.code !== 'PGRST116' + ) { + throw new Error( + `Customer lookup failed: ${customerLookupError.message}` + ); + } + + // Create or update customer if needed + if (!existingCustomer) { + const { error: createCustomerError } = await supabase + .from('customers') + .upsert({ + id: event.data.metadata.user_id, + paystack_customer_id: event.data.customer.customer_code + }); + + if (createCustomerError) { + throw new Error( + `Customer creation failed: ${createCustomerError.message}` + ); + } + } + + // Create subscription + const { error: subscriptionError } = await supabase + .from('subscriptions') + .upsert({ + id: event.data.subscription_code, + user_id: event.data.metadata.user_id, + status: 'active', + price_id: event.data.plan.plan_code, + quantity: 1, + cancel_at_period_end: false, + created: new Date(event.data.createdAt).toISOString(), + current_period_start: new Date( + event.data.current_period_start + ).toISOString(), + current_period_end: new Date( + event.data.current_period_end + ).toISOString(), + metadata: { + paystack_subscription_id: event.data.id, + paystack_status: event.data.status, + plan_name: event.data.plan.name, + customer_code: event.data.customer.customer_code + } + }); + + if (subscriptionError) { + throw new Error( + `Subscription creation failed: ${subscriptionError.message}` + ); + } + break; + + case 'subscription.disable': + const { error: disableError } = await supabase + .from('subscriptions') + .update({ + status: 'canceled', + canceled_at: new Date().toISOString(), + metadata: { + paystack_status: 'canceled', + canceled_reason: event.data.reason + } + }) + .eq('id', event.data.subscription_code); + + if (disableError) { + throw new Error( + `Subscription disable failed: ${disableError.message}` + ); + } + break; + + case 'subscription.enable': + const { error: enableError } = await supabase + .from('subscriptions') + .update({ + status: 'active', + metadata: { + paystack_status: 'active' + } + }) + .eq('id', event.data.subscription_code); + + if (enableError) { + throw new Error( + `Subscription enable failed: ${enableError.message}` + ); + } + break; + + case 'charge.success': + if (event.data.plan) { + const subscriptionCode = event.data.metadata.subscription_code; + const { error: chargeError } = await supabase + .from('subscriptions') + .update({ + status: 'active', + current_period_end: new Date( + event.data.paid_at * 1000 + + event.data.plan.interval * 86400000 + ).toISOString() + }) + .eq('id', subscriptionCode) + .eq('user_id', event.data.metadata.user_id); + + if (chargeError) { + throw new Error( + `Charge success update failed: ${chargeError.message}` + ); + } + } + break; + + default: + console.log(`🤷‍♂️ Unhandled event type: ${event.event}`); } - ); + + return new Response(JSON.stringify({ received: true })); + } catch (error) { + console.error('❌ Webhook handler failed:', error); + // Return 200 to acknowledge receipt but log the error + // This prevents Paystack from retrying webhooks that fail due to data issues + return new Response( + JSON.stringify({ + received: true, + error: error instanceof Error ? error.message : 'Unknown error' + }) + ); + } } - } else { - return new Response(`Unsupported event type: ${event.type}`, { - status: 400 - }); + + return new Response(JSON.stringify({ received: true })); + } catch (err) { + console.error('Webhook Error:', err); + return new Response( + `Webhook Error: ${err instanceof Error ? err.message : 'Unknown error'}`, + { status: 400 } + ); } - return new Response(JSON.stringify({ received: true })); } diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 000000000..fec3b89ec --- /dev/null +++ b/app/globals.css @@ -0,0 +1,70 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 20 14.3% 4.1%; + --card: 0 0% 100%; + --card-foreground: 20 14.3% 4.1%; + --popover: 0 0% 100%; + --popover-foreground: 20 14.3% 4.1%; + --primary: 24.6 95% 53.1%; + --primary-foreground: 60 9.1% 97.8%; + --secondary: 60 4.8% 95.9%; + --secondary-foreground: 24 9.8% 10%; + --muted: 60 4.8% 95.9%; + --muted-foreground: 25 5.3% 44.7%; + --accent: 60 4.8% 95.9%; + --accent-foreground: 24 9.8% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 60 9.1% 97.8%; + --border: 20 5.9% 90%; + --input: 20 5.9% 90%; + --ring: 24.6 95% 53.1%; + --radius: 1rem; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + } + + .dark { + --background: 20 14.3% 4.1%; + --foreground: 60 9.1% 97.8%; + --card: 20 14.3% 4.1%; + --card-foreground: 60 9.1% 97.8%; + --popover: 20 14.3% 4.1%; + --popover-foreground: 60 9.1% 97.8%; + --primary: 20.5 90.2% 48.2%; + --primary-foreground: 60 9.1% 97.8%; + --secondary: 12 6.5% 15.1%; + --secondary-foreground: 60 9.1% 97.8%; + --muted: 12 6.5% 15.1%; + --muted-foreground: 24 5.4% 63.9%; + --accent: 12 6.5% 15.1%; + --accent-foreground: 60 9.1% 97.8%; + --destructive: 0 72.2% 50.6%; + --destructive-foreground: 60 9.1% 97.8%; + --border: 12 6.5% 15.1%; + --input: 12 6.5% 15.1%; + --ring: 20.5 90.2% 48.2%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } +} + +@layer base { + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground; + } +} diff --git a/app/page.tsx b/app/page.tsx index 9098e1382..cdba8603d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,24 +1,218 @@ -import Pricing from '@/components/ui/Pricing/Pricing'; -import { createClient } from '@/utils/supabase/server'; +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Terminal } from '@/components/terminal'; import { - getProducts, - getSubscription, - getUser -} from '@/utils/supabase/queries'; + ArrowRight, + Calendar, + CreditCard, + Laptop, + Shield, + Smartphone, + Zap, + Github +} from 'lucide-react'; +import LogoCloud from '@/components/ui/LogoCloud'; -export default async function PricingPage() { - const supabase = createClient(); - const [user, products, subscription] = await Promise.all([ - getUser(supabase), - getProducts(supabase), - getSubscription(supabase) - ]); +export default function Component() { + const features = [ + { + icon: Zap, + title: 'Next.js and React', + description: + 'Built with Next.js 14 and App Router for optimal performance and developer experience' + }, + { + icon: Shield, + title: 'Supabase Backend', + description: + 'Secure authentication and PostgreSQL database with powerful management tools' + }, + { + icon: CreditCard, + title: 'Dual Payment Integration', + description: + 'Stripe for international payments and Paystack for local African market payments' + } + ]; return ( - +
+ {/* Hero Section */} +
+
+
+
+
+ + Now Available + + + MPESA Coming Soon + +
+

+ Build Your SaaS + + For African Markets + +

+

+ Production-ready SaaS starter with international and local + payment integrations. Built for businesses targeting the African + market. +

+ +
+
+ +
+
+
+
+ + {/* Features Section */} +
+
+
+ {features.map((feature) => ( +
+
+ +
+
+

{feature.title}

+

+ {feature.description} +

+
+
+ ))} +
+
+
+ + {/* Tech Stack Section */} +
+
+

+ Built With Modern Stack +

+ +
+
+ + {/* CTA Section */} +
+
+
+
+

+ Ready to Build Your SaaS? +

+

+ Get started with our template and launch your SaaS product with + support for both international and local African payment + methods. +

+
+ +
+
+
+ + {/* Coming Soon Banner */} +
+
+
+ +

+ MPESA Integration Coming Soon - Get Early Access! +

+ +
+
+
+
); } diff --git a/app/pricing/page.tsx b/app/pricing/page.tsx new file mode 100644 index 000000000..9098e1382 --- /dev/null +++ b/app/pricing/page.tsx @@ -0,0 +1,24 @@ +import Pricing from '@/components/ui/Pricing/Pricing'; +import { createClient } from '@/utils/supabase/server'; +import { + getProducts, + getSubscription, + getUser +} from '@/utils/supabase/queries'; + +export default async function PricingPage() { + const supabase = createClient(); + const [user, products, subscription] = await Promise.all([ + getUser(supabase), + getProducts(supabase), + getSubscription(supabase) + ]); + + return ( + + ); +} diff --git a/components/terminal.tsx b/components/terminal.tsx new file mode 100644 index 000000000..84c792874 --- /dev/null +++ b/components/terminal.tsx @@ -0,0 +1,144 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Copy, Check } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; + +export function Terminal() { + const [terminalStep, setTerminalStep] = useState(0); + const [copied, setCopied] = useState(false); + + const terminalSteps = [ + '# Clone and install', + 'git clone https://github.com/farajabien/supabase-saas-starter', + 'cd supabase-saas-starter', + 'pnpm install', + '', + '# Configure environment', + 'cp .env.example .env.local', + '', + '# Set up Supabase', + 'pnpm supabase:start', + 'pnpm supabase:migrate', + '', + '# Configure payments', + '# Add your Stripe keys', + 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...', + 'STRIPE_SECRET_KEY=sk_test_...', + '', + '# Add your Paystack keys (Optional)', + 'NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY=pk_test_...', + 'PAYSTACK_SECRET_KEY=sk_test_...', + '', + '# Start development', + 'pnpm dev', + '', + '# Set up webhooks (in new terminal)', + 'pnpm stripe:listen', + '', + '🎉 Ready at http://localhost:3000', + '', + '# MPESA Integration', + '# Coming Soon - Join waitlist on WhatsApp' + ]; + + useEffect(() => { + const timer = setTimeout(() => { + setTerminalStep((prev) => + prev < terminalSteps.length - 1 ? prev + 1 : prev + ); + }, 300); + + return () => clearTimeout(timer); + }, [terminalStep]); + + const copyToClipboard = () => { + const commands = terminalSteps + .filter( + (step) => + !step.startsWith('#') && + !step.startsWith('NEXT_PUBLIC') && + !step.startsWith('STRIPE_') && + !step.startsWith('PAYSTACK_') && + !step.startsWith('🎉') && + step.trim() !== '' + ) + .join('\n'); + navigator.clipboard.writeText(commands); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const getLineColor = (step: string) => { + if (step.startsWith('#')) return 'text-gray-500'; + if (step.includes('Coming Soon')) return 'text-orange-400'; + if ( + step.startsWith('NEXT_PUBLIC') || + step.startsWith('STRIPE_') || + step.startsWith('PAYSTACK_') + ) + return 'text-blue-400'; + if (step.startsWith('🎉')) return 'text-green-400'; + return ''; + }; + + return ( +
+
+ Paystack Ready + + MPESA Soon + +
+
+
+
+
+
+
+
+
+
+ + {copied ? 'Copied!' : 'Copy commands'} + + +
+
+
+ {terminalSteps.map((step, index) => ( +
terminalStep ? 'opacity-0' : 'opacity-100'} + transition-opacity duration-300 + ${getLineColor(step)} + `} + > + {!step.startsWith('#') && + !step.startsWith('NEXT_PUBLIC') && + !step.startsWith('STRIPE_') && + !step.startsWith('PAYSTACK_') && + !step.startsWith('🎉') && + step.trim() !== '' && ( + $ + )}{' '} + {step} +
+ ))} +
+
+
+
+ ); +} diff --git a/components/ui/AccountForms/AccountForm.tsx b/components/ui/AccountForms/AccountForm.tsx new file mode 100644 index 000000000..4af2c4b19 --- /dev/null +++ b/components/ui/AccountForms/AccountForm.tsx @@ -0,0 +1,139 @@ +// components/AccountForm.tsx +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Button from '@/components/ui/CustomButton'; +import { toast } from 'sonner'; +import { Tables } from '@/types_db'; +import { User } from '@supabase/supabase-js'; +interface Props { + user: Tables<'users'> & Pick; + subscription: + | (Tables<'subscriptions'> & { + prices: + | (Tables<'prices'> & { + products: Tables<'products'> | null; + }) + | null; + }) + | null; +} + +export default function AccountForm({ user, subscription }: Props) { + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const [formData, setFormData] = useState({ + fullName: user?.full_name || '', + email: user?.email || '' + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + + try { + const response = await fetch('/api/account/update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + fullName: formData.fullName, + email: formData.email + }) + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to update account'); + } + + toast.success('Account updated successfully'); + router.refresh(); + } catch (error) { + console.error('Error updating account:', error); + toast.error( + error instanceof Error ? error.message : 'Failed to update account' + ); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+ + + setFormData((prev) => ({ ...prev, fullName: e.target.value })) + } + maxLength={64} + className="mt-1 block w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-white shadow-sm focus:border-pink-500 focus:ring-pink-500 sm:text-sm" + /> +
+ +
+ + + setFormData((prev) => ({ ...prev, email: e.target.value })) + } + className="mt-1 block w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-white shadow-sm focus:border-pink-500 focus:ring-pink-500 sm:text-sm" + /> +
+ + {subscription && ( +
+

+ Subscription Status:{' '} + + {subscription.status} + +

+

+ Current Plan:{' '} + + {subscription.prices?.products?.name} + +

+

+ Next billing:{' '} + + {new Date(subscription.current_period_end).toLocaleDateString()} + +

+
+ )} + +
+ +
+
+ ); +} diff --git a/components/ui/AccountForms/CustomerPortalForm.tsx b/components/ui/AccountForms/CustomerPortalForm.tsx index 2fc1d7c4c..5e2f11505 100644 --- a/components/ui/AccountForms/CustomerPortalForm.tsx +++ b/components/ui/AccountForms/CustomerPortalForm.tsx @@ -1,12 +1,12 @@ 'use client'; -import Button from '@/components/ui/Button'; -import { useRouter, usePathname } from 'next/navigation'; +import { useRouter } from 'next/navigation'; import { useState } from 'react'; -import { createStripePortal } from '@/utils/stripe/server'; import Link from 'next/link'; import Card from '@/components/ui/Card'; import { Tables } from '@/types_db'; +import Button from '../CustomButton'; +import { toast } from 'sonner'; type Subscription = Tables<'subscriptions'>; type Price = Tables<'prices'>; @@ -26,7 +26,6 @@ interface Props { export default function CustomerPortalForm({ subscription }: Props) { const router = useRouter(); - const currentPath = usePathname(); const [isSubmitting, setIsSubmitting] = useState(false); const subscriptionPrice = @@ -37,11 +36,31 @@ export default function CustomerPortalForm({ subscription }: Props) { minimumFractionDigits: 0 }).format((subscription?.prices?.unit_amount || 0) / 100); - const handleStripePortalRequest = async () => { + const handleSubscriptionCancel = async () => { setIsSubmitting(true); - const redirectUrl = await createStripePortal(currentPath); - setIsSubmitting(false); - return router.push(redirectUrl); + try { + const response = await fetch('/api/subscription/cancel', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + subscriptionId: subscription?.id + }) + }); + + if (!response.ok) { + throw new Error('Failed to cancel subscription'); + } + + toast.success('Subscription cancelled successfully'); + router.refresh(); + } catch (error) { + console.error('Error cancelling subscription:', error); + toast.error('Failed to cancel subscription'); + } finally { + setIsSubmitting(false); + } }; return ( @@ -54,22 +73,34 @@ export default function CustomerPortalForm({ subscription }: Props) { } footer={
-

Manage your subscription on Stripe.

- +

+ Manage your subscription with Paystack. +

+ {subscription && ( + + )}
} >
{subscription ? ( - `${subscriptionPrice}/${subscription?.prices?.interval}` + <> + {subscriptionPrice}/{subscription?.prices?.interval} +
+ Status: {subscription.status} +
+ Next billing date:{' '} + {new Date(subscription.current_period_end).toLocaleDateString()} +
+ ) : ( - Choose your plan + Choose your plan )}
diff --git a/components/ui/AccountForms/EmailForm.tsx b/components/ui/AccountForms/EmailForm.tsx index b7e05aa3f..93786473a 100644 --- a/components/ui/AccountForms/EmailForm.tsx +++ b/components/ui/AccountForms/EmailForm.tsx @@ -1,6 +1,6 @@ 'use client'; -import Button from '@/components/ui/Button'; +import Button from '@/components/ui/CustomButton'; import Card from '@/components/ui/Card'; import { updateEmail } from '@/utils/auth-helpers/server'; import { handleRequest } from '@/utils/auth-helpers/client'; diff --git a/components/ui/AccountForms/ManageSubscriptionForm.tsx b/components/ui/AccountForms/ManageSubscriptionForm.tsx new file mode 100644 index 000000000..d511e3b31 --- /dev/null +++ b/components/ui/AccountForms/ManageSubscriptionForm.tsx @@ -0,0 +1,152 @@ +// components/ManageSubscriptionForm.tsx +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { toast } from 'sonner'; +import { Tables } from '@/types_db'; +import Button from '@/components/ui/CustomButton'; + +interface Props { + subscription: Tables<'subscriptions'> & { + prices: + | (Tables<'prices'> & { + products: Tables<'products'> | null; + }) + | null; + }; +} + +export default function ManageSubscriptionForm({ subscription }: Props) { + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: subscription.prices?.currency || 'USD', + minimumFractionDigits: 0 + }).format(amount / 100); + }; + + const handleCancel = async () => { + setIsLoading(true); + try { + const response = await fetch('/api/subscription/cancel', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ subscriptionId: subscription.id }) + }); + + if (!response.ok) throw new Error('Failed to cancel subscription'); + + toast.success('Subscription cancelled successfully'); + router.refresh(); + } catch (error) { + console.error('Error cancelling subscription:', error); + toast.error('Failed to cancel subscription'); + } finally { + setIsLoading(false); + } + }; + + const handleReactivate = async () => { + setIsLoading(true); + try { + const response = await fetch('/api/subscription/reactivate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ subscriptionId: subscription.id }) + }); + + if (!response.ok) throw new Error('Failed to reactivate subscription'); + + toast.success('Subscription reactivated successfully'); + router.refresh(); + } catch (error) { + console.error('Error reactivating subscription:', error); + toast.error('Failed to reactivate subscription'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ {/* Current Plan Details */} +
+

+ {subscription.prices?.products?.name} Plan +

+

+ {formatCurrency(subscription.prices?.unit_amount || 0)}/ + {subscription.prices?.interval} +

+
+ + {/* Status and Actions */} +
+
+

+ Status:{' '} + {subscription.status} +

+

+ Next billing date:{' '} + {new Date(subscription.current_period_end).toLocaleDateString()} +

+
+ + {/* Action Buttons */} +
+ {subscription.status === 'active' && + !subscription.cancel_at_period_end && ( + + )} + + {subscription.status === 'active' && + subscription.cancel_at_period_end && ( +
+

+ Your subscription will end on{' '} + {new Date( + subscription.current_period_end + ).toLocaleDateString()} +

+ +
+ )} + + {subscription.status === 'canceled' && ( +
+

+ Your subscription has ended. Choose a new plan to continue. +

+ +
+ )} +
+
+
+ ); +} diff --git a/components/ui/AccountForms/NameForm.tsx b/components/ui/AccountForms/NameForm.tsx index 269fac55a..d42894144 100644 --- a/components/ui/AccountForms/NameForm.tsx +++ b/components/ui/AccountForms/NameForm.tsx @@ -1,6 +1,6 @@ 'use client'; -import Button from '@/components/ui/Button'; +import Button from '@/components/ui/CustomButton'; import Card from '@/components/ui/Card'; import { updateName } from '@/utils/auth-helpers/server'; import { handleRequest } from '@/utils/auth-helpers/client'; diff --git a/components/ui/AuthForms/EmailSignIn.tsx b/components/ui/AuthForms/EmailSignIn.tsx index 1860f729c..70a435620 100644 --- a/components/ui/AuthForms/EmailSignIn.tsx +++ b/components/ui/AuthForms/EmailSignIn.tsx @@ -1,6 +1,6 @@ 'use client'; -import Button from '@/components/ui/Button'; +import Button from '@/components/ui/CustomButton'; import Link from 'next/link'; import { signInWithEmail } from '@/utils/auth-helpers/server'; import { handleRequest } from '@/utils/auth-helpers/client'; diff --git a/components/ui/AuthForms/ForgotPassword.tsx b/components/ui/AuthForms/ForgotPassword.tsx index bdf8f6b7e..43194ce57 100644 --- a/components/ui/AuthForms/ForgotPassword.tsx +++ b/components/ui/AuthForms/ForgotPassword.tsx @@ -1,6 +1,6 @@ 'use client'; -import Button from '@/components/ui/Button'; +import Button from '@/components/ui/CustomButton'; import Link from 'next/link'; import { requestPasswordUpdate } from '@/utils/auth-helpers/server'; import { handleRequest } from '@/utils/auth-helpers/client'; diff --git a/components/ui/AuthForms/OauthSignIn.tsx b/components/ui/AuthForms/OauthSignIn.tsx index 7455d50b1..c15ed2b28 100644 --- a/components/ui/AuthForms/OauthSignIn.tsx +++ b/components/ui/AuthForms/OauthSignIn.tsx @@ -1,6 +1,6 @@ 'use client'; -import Button from '@/components/ui/Button'; +import Button from '@/components/ui/CustomButton'; import { signInWithOAuth } from '@/utils/auth-helpers/client'; import { type Provider } from '@supabase/supabase-js'; import { Github } from 'lucide-react'; diff --git a/components/ui/AuthForms/PasswordSignIn.tsx b/components/ui/AuthForms/PasswordSignIn.tsx index 3ec8297d2..ec20f34ae 100644 --- a/components/ui/AuthForms/PasswordSignIn.tsx +++ b/components/ui/AuthForms/PasswordSignIn.tsx @@ -1,6 +1,6 @@ 'use client'; -import Button from '@/components/ui/Button'; +import Button from '@/components/ui/CustomButton'; import Link from 'next/link'; import { signInWithPassword } from '@/utils/auth-helpers/server'; import { handleRequest } from '@/utils/auth-helpers/client'; diff --git a/components/ui/AuthForms/Signup.tsx b/components/ui/AuthForms/Signup.tsx index cd98f0407..3faf73199 100644 --- a/components/ui/AuthForms/Signup.tsx +++ b/components/ui/AuthForms/Signup.tsx @@ -1,6 +1,6 @@ 'use client'; -import Button from '@/components/ui/Button'; +import Button from '@/components/ui/CustomButton'; import React from 'react'; import Link from 'next/link'; import { signUp } from '@/utils/auth-helpers/server'; diff --git a/components/ui/AuthForms/UpdatePassword.tsx b/components/ui/AuthForms/UpdatePassword.tsx index cd51488ee..52ffe89b7 100644 --- a/components/ui/AuthForms/UpdatePassword.tsx +++ b/components/ui/AuthForms/UpdatePassword.tsx @@ -1,6 +1,6 @@ 'use client'; -import Button from '@/components/ui/Button'; +import Button from '@/components/ui/CustomButton'; import { updatePassword } from '@/utils/auth-helpers/server'; import { handleRequest } from '@/utils/auth-helpers/client'; import { useRouter } from 'next/navigation'; diff --git a/components/ui/Button/Button.module.css b/components/ui/Button/Button.module.css deleted file mode 100644 index d9efa589f..000000000 --- a/components/ui/Button/Button.module.css +++ /dev/null @@ -1,32 +0,0 @@ -.root { - @apply bg-white text-zinc-800 cursor-pointer inline-flex px-10 rounded-sm leading-6 transition ease-in-out duration-150 shadow-sm font-semibold text-center justify-center uppercase py-4 border border-transparent items-center; -} - -.root:hover { - @apply bg-zinc-800 text-white border border-white; -} - -.root:focus { - @apply outline-none ring-2 ring-pink-500 ring-opacity-50; -} - -.root[data-active] { - @apply bg-zinc-600; -} - -.loading { - @apply bg-zinc-700 text-zinc-500 border-zinc-600 cursor-not-allowed; -} - -.slim { - @apply py-2 transform-none normal-case; -} - -.disabled, -.disabled:hover { - @apply text-zinc-400 border-zinc-600 bg-zinc-700 cursor-not-allowed; - filter: grayscale(1); - -webkit-transform: translateZ(0); - -webkit-perspective: 1000; - -webkit-backface-visibility: hidden; -} diff --git a/components/ui/Button/Button.tsx b/components/ui/Button/Button.tsx deleted file mode 100644 index cb9148537..000000000 --- a/components/ui/Button/Button.tsx +++ /dev/null @@ -1,66 +0,0 @@ -'use client'; - -import cn from 'classnames'; -import React, { forwardRef, useRef, ButtonHTMLAttributes } from 'react'; -import { mergeRefs } from 'react-merge-refs'; - -import LoadingDots from '@/components/ui/LoadingDots'; - -import styles from './Button.module.css'; - -interface Props extends ButtonHTMLAttributes { - variant?: 'slim' | 'flat'; - active?: boolean; - width?: number; - loading?: boolean; - Component?: React.ComponentType; -} - -const Button = forwardRef((props, buttonRef) => { - const { - className, - variant = 'flat', - children, - active, - width, - loading = false, - disabled = false, - style = {}, - Component = 'button', - ...rest - } = props; - const ref = useRef(null); - const rootClassName = cn( - styles.root, - { - [styles.slim]: variant === 'slim', - [styles.loading]: loading, - [styles.disabled]: disabled - }, - className - ); - return ( - - {children} - {loading && ( - - - - )} - - ); -}); -Button.displayName = 'Button'; - -export default Button; diff --git a/components/ui/CustomButton/Button 2.tsx b/components/ui/CustomButton/Button 2.tsx new file mode 100644 index 000000000..796f19171 --- /dev/null +++ b/components/ui/CustomButton/Button 2.tsx @@ -0,0 +1,74 @@ +'use client'; + +import React, { forwardRef, useRef, ButtonHTMLAttributes } from 'react'; +import { mergeRefs } from 'react-merge-refs'; +import cn from 'classnames'; +import LoadingDots from '@/components/ui/LoadingDots'; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: 'slim' | 'flat'; + active?: boolean; + width?: number; + loading?: boolean; + Component?: React.ComponentType; +} + +const Button = forwardRef( + (props, buttonRef) => { + const { + className, + variant = 'flat', + children, + active, + width, + loading = false, + disabled = false, + style = {}, + Component = 'button', + ...rest + } = props; + + const ref = useRef(null); + + const rootClassName = cn( + 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background', + { + 'h-10 py-2 px-4': variant === 'flat', + 'h-9 px-3': variant === 'slim', + 'bg-primary text-primary-foreground hover:bg-primary/90': + variant === 'flat', + 'border border-input hover:bg-accent hover:text-accent-foreground': + variant === 'slim', + 'cursor-not-allowed opacity-60': loading, + 'bg-secondary text-secondary-foreground': active + }, + className + ); + + return ( + + {children} + {loading && ( + + + + )} + + ); + } +); + +Button.displayName = 'Button'; + +export default Button; diff --git a/components/ui/CustomButton/Button.tsx b/components/ui/CustomButton/Button.tsx new file mode 100644 index 000000000..796f19171 --- /dev/null +++ b/components/ui/CustomButton/Button.tsx @@ -0,0 +1,74 @@ +'use client'; + +import React, { forwardRef, useRef, ButtonHTMLAttributes } from 'react'; +import { mergeRefs } from 'react-merge-refs'; +import cn from 'classnames'; +import LoadingDots from '@/components/ui/LoadingDots'; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: 'slim' | 'flat'; + active?: boolean; + width?: number; + loading?: boolean; + Component?: React.ComponentType; +} + +const Button = forwardRef( + (props, buttonRef) => { + const { + className, + variant = 'flat', + children, + active, + width, + loading = false, + disabled = false, + style = {}, + Component = 'button', + ...rest + } = props; + + const ref = useRef(null); + + const rootClassName = cn( + 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background', + { + 'h-10 py-2 px-4': variant === 'flat', + 'h-9 px-3': variant === 'slim', + 'bg-primary text-primary-foreground hover:bg-primary/90': + variant === 'flat', + 'border border-input hover:bg-accent hover:text-accent-foreground': + variant === 'slim', + 'cursor-not-allowed opacity-60': loading, + 'bg-secondary text-secondary-foreground': active + }, + className + ); + + return ( + + {children} + {loading && ( + + + + )} + + ); + } +); + +Button.displayName = 'Button'; + +export default Button; diff --git a/components/ui/Button/index.ts b/components/ui/CustomButton/index 2.ts similarity index 100% rename from components/ui/Button/index.ts rename to components/ui/CustomButton/index 2.ts diff --git a/components/ui/CustomButton/index.ts b/components/ui/CustomButton/index.ts new file mode 100644 index 000000000..efe8c800c --- /dev/null +++ b/components/ui/CustomButton/index.ts @@ -0,0 +1 @@ +export { default } from './Button'; diff --git a/components/ui/Footer/Footer.tsx b/components/ui/Footer/Footer.tsx index 1f0405676..164354758 100644 --- a/components/ui/Footer/Footer.tsx +++ b/components/ui/Footer/Footer.tsx @@ -1,110 +1,171 @@ import Link from 'next/link'; - -import Logo from '@/components/icons/Logo'; -import GitHub from '@/components/icons/GitHub'; +import { + Github, + Twitter, + Linkedin, + Mail, + Globe, + FileCode, + Users, + BookOpen +} from 'lucide-react'; export default function Footer() { + const socialLinks = [ + { + icon: Github, + href: 'https://github.com/farajabien', + label: 'GitHub' + }, + { + icon: Twitter, + href: 'https://twitter.com/farajabien', + label: 'Twitter' + }, + { + icon: Linkedin, + href: 'https://linkedin.com/in/bienvenufaraja', + label: 'LinkedIn' + }, + { + icon: Mail, + href: 'mailto:farajabien@gmail.com', + label: 'Email' + } + ]; + return ( -
-
-
- - - - - ACME - -
-
-
    -
  • - - Home - -
  • -
  • - - About - -
  • -
  • - - Careers - -
  • -
  • - - Blog - -
  • -
-
-
-
    -
  • -

    - LEGAL -

    -
  • -
  • - - Privacy Policy - -
  • -
  • - - Terms of Use - -
  • -
-
-
-
- +
+
+ {/* Brand Section */} +
+ - - + + Faraja Bien + +

+ Building SaaS products for African markets with modern tech stack + and local payment integrations. +

+
+ + {/* Services Section */} +
+

Services

+
    +
  • + + Startup Validation + +
  • +
  • + + MVP Development + +
  • +
  • + + SaaS Template + +
  • +
+
+ + {/* Resources Section */} +
+

Resources

+
    +
  • + + Free SaaS Template + +
  • +
  • + + Validation Framework + +
  • +
  • + + Open Source Projects + +
  • +
+
+ + {/* Quick Links Section */} +
+

Quick Links

+
-
-
-
- - © {new Date().getFullYear()} ACME, Inc. All rights reserved. - -
-
- Crafted by - - Vercel.com Logo - + + {/* Bottom Section */} +
+
+ © {new Date().getFullYear()} Faraja Bien. All rights reserved. +
+
+ {socialLinks.map((link) => ( + + + + ))} +
diff --git a/components/ui/LogoCloud/LogoCloud.tsx b/components/ui/LogoCloud/LogoCloud.tsx index 3dd842029..56e9c9410 100644 --- a/components/ui/LogoCloud/LogoCloud.tsx +++ b/components/ui/LogoCloud/LogoCloud.tsx @@ -1,55 +1,67 @@ -export default function LogoCloud() { +export default function LogoCloud({ + items = [ + { + logo: '/nextjs.svg', + name: 'Next.js', + description: 'Next.js Logo', + link: 'https://nextjs.org' + }, + { + logo: '/vercel.svg', + name: 'Vercel', + description: 'Vercel Logo', + link: 'https://vercel.com' + }, + { + logo: '/stripe.svg', + name: 'Stripe', + description: 'Stripe Logo', + link: 'https://stripe.com' + }, + { + logo: '/supabase.svg', + name: 'Supabase', + description: 'Supabase Logo', + link: 'https://supabase.io' + }, + { + logo: 'https://upload.wikimedia.org/wikipedia/commons/thumb/1/1f/Paystack.png/440px-Paystack.png', + name: 'Paystack', + description: 'Paystack Logo', + link: 'https://paystack.com' + }, + { + logo: '/github.svg', + name: 'GitHub', + description: 'GitHub Logo', + link: 'https://github.com' + } + ] +}: { + items?: { + logo: string; + name: string; + description: string; + link: string; + }[]; +}) { return (

Brought to you by

-
-
- - Next.js Logo - -
-
- - Vercel.com Logo - -
-
- - stripe.com Logo - -
-
- - supabase.io Logo - -
-
- - github.com Logo - -
+
+ {items?.map((item, index) => ( +
+ + {`${item.name} + +
+ ))}
); diff --git a/components/ui/Navbar/Navlinks.tsx b/components/ui/Navbar/Navlinks.tsx index 6608cd0a0..28be3146a 100644 --- a/components/ui/Navbar/Navlinks.tsx +++ b/components/ui/Navbar/Navlinks.tsx @@ -7,6 +7,7 @@ import Logo from '@/components/icons/Logo'; import { usePathname, useRouter } from 'next/navigation'; import { getRedirectMethod } from '@/utils/auth-helpers/settings'; import s from './Navbar.module.css'; +import Image from 'next/image'; interface NavlinksProps { user?: any; @@ -19,10 +20,10 @@ export default function Navlinks({ user }: NavlinksProps) {
- + Logo