### Clone and Install Dependencies Source: https://github.com/zak123dsfdf/refearnapp/blob/main/CONTRIBUTING.md Clones the repository, navigates into the project directory, and installs dependencies using pnpm. ```bash git clone https://github.com/YOUR_USERNAME/refearnapp.git cd refearnapp pnpm install ``` -------------------------------- ### Run the development server Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/simulator/README.md Starts the Next.js development server. Use one of the commands depending on your package manager (npm, yarn, pnpm, or bun). After starting, open http://localhost:3000 to view the app. ```bash npm run dev # or yarn dev # or pnpm dev # or bun dev ``` -------------------------------- ### Build and start production server Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/self-hosted-affiliate-tracking-software.mdx These commands build the production server and then execute it. Run them in sequence after pushing the schema to ensure the application is up-to-date and running in a production environment. ```bash pnpm build ``` ```bash pnpm start ``` -------------------------------- ### Install dependencies Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/self-hosted-affiliate-tracking-software.mdx Installs project dependencies using pnpm. ```bash # Install project dependencies pnpm install ``` -------------------------------- ### Astro CLI commands Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/README.md Lists common pnpm commands for installing dependencies, running the dev server, building, previewing, and using the Astro CLI. ```text pnpm install ``` ```text pnpm dev ``` ```text pnpm build ``` ```text pnpm preview ``` ```text pnpm astro ... ``` ```text pnpm astro -- --help ``` -------------------------------- ### Start ngrok Tunnel Source: https://github.com/zak123dsfdf/refearnapp/blob/main/CONTRIBUTING.md Starts ngrok to expose your local server for testing tracking redirects. Use the generated tunnel URL in the NEXT_PUBLIC_REDIRECTION_URL variable. ```bash ngrok http 3000 ``` -------------------------------- ### Start Mailpit Local Mail Engine Source: https://github.com/zak123dsfdf/refearnapp/blob/main/CONTRIBUTING.md Starts the local mail engine (Mailpit) to intercept all testing transactional emails. Must be installed and run globally. ```bash mailpit ``` -------------------------------- ### Example Referral Link Formats Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/how-to-launch-a-referral-loop-for-saas.mdx Shows the format of unique referral links. The slug (username or abc123) ties a visit back to the specific referrer and must be unique per user and persistent. ```text yoursaas.com?ref=username // or yoursaas.com?ref=abc123 ``` -------------------------------- ### Stripe webhook handler for referral conversions and recurring commissions Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/how-to-build-a-referral-system-in-react.mdx Handles Stripe webhook events for checkout.session.completed and invoice.payment_succeeded. It verifies the signature, checks for idempotency, and creates referral conversion or commission records. For subscription renewals, it retrieves the subscription to get the affiliate_ref metadata. ```typescript export async function POST(request: NextRequest): Promise { const body = await request.text(); const sig = request.headers.get('stripe-signature'); if (!sig) return NextResponse.json({ error: 'No signature' }, { status: 400 }); let event: Stripe.Event; try { event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!); } catch { return NextResponse.json({ error: 'Invalid signature' }, { status: 400 }); } if (event.type === 'checkout.session.completed') { const session = event.data.object as Stripe.Checkout.Session; const ref = session.metadata?.affiliate_ref; if (ref && session.payment_status === 'paid') { // Idempotency guard const exists = await db.referralConversion.findUnique({ where: { stripeSessionId: session.id }, }); if (!exists) { await db.referralConversion.create({ data: { referralSlug: ref, stripeSessionId: session.id, stripeCustomerId: session.customer as string, amountCents: session.amount_total ?? 0, commissionCents: Math.floor((session.amount_total ?? 0) * 0.2), status: 'pending', convertedAt: new Date(), }, }); } } } // Recurring commissions on subscription renewals if (event.type === 'invoice.payment_succeeded') { const invoice = event.data.object as Stripe.Invoice; if (invoice.billing_reason !== 'subscription_cycle') { return NextResponse.json({ received: true }); } const sub = await stripe.subscriptions.retrieve(invoice.subscription as string); const ref = sub.metadata?.affiliate_ref; if (ref && invoice.amount_paid) { await db.referralCommission.create({ data: { referralSlug: ref, stripeInvoiceId: invoice.id, amountCents: invoice.amount_paid, commissionCents: Math.floor(invoice.amount_paid * 0.2), status: 'pending', paidAt: new Date(), }, }); } } return NextResponse.json({ received: true }); } ``` -------------------------------- ### Create a new Astro project with the minimal template Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/README.md Run this command to scaffold a new Astro project using the minimal template. ```sh pnpm create astro@latest -- --template minimal ``` -------------------------------- ### Create Simulator .env Source: https://github.com/zak123dsfdf/refearnapp/blob/main/CONTRIBUTING.md Creates the environment file for the simulator sandbox. ```bash touch apps/simulator/.env ``` -------------------------------- ### Run All Apps with pnpm dev Source: https://github.com/zak123dsfdf/refearnapp/blob/main/CONTRIBUTING.md Launches all apps concurrently from the root directory. Use this option to spin up the entire global workspace. ```bash pnpm dev ``` -------------------------------- ### Configure .env for production Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/self-hosted-affiliate-tracking-software.mdx Sets the production database URL, Stripe keys, NextAuth secret, and public app URL in the .env file. ```bash DATABASE_URL="postgresql://user:password@host:5432/refearnapp" STRIPE_SECRET_KEY="sk_live_..." STRIPE_WEBHOOK_SECRET="whsec_..." NEXTAUTH_SECRET="your-super-secret-key-here" NEXT_PUBLIC_APP_URL="https://affiliates.yourapp.com" ``` -------------------------------- ### Create affiliate tracking tables in SQL Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/stripe-coupon-affiliate-tracking.mdx SQL schema for affiliates, affiliate coupon codes, and conversion tracking. Includes indexes for performance. Use this to set up the database tables needed to associate coupon codes with affiliates and record conversion events. ```sql -- Affiliates table (you likely already have this) CREATE TABLE affiliates ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), slug TEXT UNIQUE NOT NULL, name TEXT NOT NULL, email TEXT UNIQUE NOT NULL, commission_rate NUMERIC(5,4) NOT NULL DEFAULT 0.20, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- One row per promotion code assigned to an affiliate -- An affiliate can have multiple codes (e.g., different campaigns) CREATE TABLE affiliate_coupon_codes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), affiliate_id UUID NOT NULL REFERENCES affiliates(id) ON DELETE CASCADE, stripe_coupon_id TEXT NOT NULL, -- e.g., "SUMMER20" stripe_promotion_code_id TEXT, -- e.g., "promo_1ABC..." code TEXT NOT NULL, -- Human-readable code, e.g., "ZAK20" discount_type TEXT NOT NULL, -- 'percentage' | 'fixed' discount_value NUMERIC(10,2) NOT NULL, -- e.g., 20.00 (%) or 10.00 ($) max_redemptions INTEGER, -- NULL = unlimited times_redeemed INTEGER NOT NULL DEFAULT 0, active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Conversion events attributed via coupon CREATE TABLE affiliate_coupon_conversions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), affiliate_id UUID NOT NULL REFERENCES affiliates(id), coupon_code_id UUID NOT NULL REFERENCES affiliate_coupon_codes(id), stripe_session_id TEXT UNIQUE NOT NULL, stripe_customer_id TEXT NOT NULL, stripe_subscription_id TEXT, -- NULL for one-time payments amount_cents INTEGER NOT NULL, discount_applied_cents INTEGER NOT NULL DEFAULT 0, commission_cents INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'pending', -- pending | approved | paid | reversed converted_at TIMESTAMPTZ NOT NULL, paid_at TIMESTAMPTZ ); CREATE INDEX idx_coupon_codes_affiliate ON affiliate_coupon_codes(affiliate_id); CREATE INDEX idx_coupon_codes_stripe_coupon ON affiliate_coupon_codes(stripe_coupon_id); CREATE INDEX idx_coupon_codes_promo_id ON affiliate_coupon_codes(stripe_promotion_code_id); CREATE INDEX idx_coupon_conversions_affiliate ON affiliate_coupon_conversions(affiliate_id); CREATE INDEX idx_coupon_conversions_status ON affiliate_coupon_conversions(status); ``` -------------------------------- ### Simulator Environment Variables Source: https://github.com/zak123dsfdf/refearnapp/blob/main/CONTRIBUTING.md Populate apps/simulator/.env with sandbox test credentials for Stripe and Paddle. Use Connected/Merchant Test Accounts for Stripe and Sandbox tokens for Paddle. ```text # --- Stripe Integration --- # (Note: Use credentials associated with the Connected/Merchant Test Accounts here, NOT the Main platform account) STRIPE_SECRET_KEY=sk_test_... NEXT_PUBLIC_STRIPE_PLAN_20_ID=price_... NEXT_PUBLIC_STRIPE_PLAN_40_ID=price_... NEXT_PUBLIC_STRIPE_LIFETIME_ID=price_... # --- Paddle Integration --- # (Note: Use Sandbox tokens and mapping configurations) PADDLE_NOTIFICATION_SETTING_ID=ntf_... NEXT_PUBLIC_PADDLE_PRICE_PRO=pri_... NEXT_PUBLIC_PADDLE_PRICE_ULTIMATE=pri_... NEXT_PUBLIC_PADDLE_PRICE_ONE_TIME=pri_... PADDLE_SECRET_TOKEN=pdl_sdbx_apikey_... PADDLE_API_KEY=pdl_sdbx_apikey_... PADDLE_WEBHOOK_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n..." ``` -------------------------------- ### Copy environment template Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/self-hosted-affiliate-tracking-software.mdx Copies the environment template file to create a local .env file for configuration. ```bash cp .env.example .env ``` -------------------------------- ### Local Hosts File Entries Source: https://github.com/zak123dsfdf/refearnapp/blob/main/CONTRIBUTING.md Append these lines to your hosts file to map local domains for Caddy and multi-tenant subdomains. Comment them out with '#' to switch back to production. ```text 127.0.0.1 refearnapp.com 127.0.0.1 xyz.refearnapp.com 127.0.0.1 xmm.refearnapp.com 127.0.0.1 shipfast.refearnapp.com 127.0.0.1 shipfast.com 127.0.0.1 my.shipfast.com 127.0.0.1 simulator.test ``` -------------------------------- ### Tracking Worker Environment Variables Source: https://github.com/zak123dsfdf/refearnapp/blob/main/CONTRIBUTING.md Populate apps/tracking-worker/.dev.vars with these variables for local development. No ngrok is needed for basic UI/Click testing. ```text # General ENVIRONMENT=development IS_SELF_HOSTED=false INTERNAL_SECRET=your_secret_key # Local Routing - No ngrok needed for basic UI/Click testing MAIN_APP_URL=http://localhost:3000 PAGES_URL=http://localhost:4321 PRIMARY_HOST=localhost:8787 # Simulator Routing SIMULATOR_URL=http://localhost:3001 UPSTASH_REDIS_REST_URL="https://your-instance.upstash.io" UPSTASH_REDIS_REST_TOKEN="AXzOAAIncDJjNDczNDdiZWYyZmE0..." ``` -------------------------------- ### Create Tracking Worker .dev.vars Source: https://github.com/zak123dsfdf/refearnapp/blob/main/CONTRIBUTING.md Creates the .dev.vars file for the tracking worker. ```bash touch apps/tracking-worker/.dev.vars ``` -------------------------------- ### SQL Schema for Affiliate Tracking (prisma/schema.sql) Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/opensource-alternative-to-rewardful.mdx SQL schema for managing affiliates, tracking links, and click analytics in PostgreSQL. Defines tables for affiliates, affiliate_links, and affiliate_clicks with foreign key constraints and indexes for query performance. ```sql -- Base affiliate identity CREATE TABLE affiliates ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), slug TEXT UNIQUE NOT NULL, status TEXT NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- First-party tracking links generated for affiliates CREATE TABLE affiliate_links ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), affiliate_id UUID NOT NULL REFERENCES affiliates(id) ON DELETE CASCADE, target_url TEXT NOT NULL, is_active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Edge click analytics table CREATE TABLE affiliate_clicks ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), affiliate_id UUID NOT NULL REFERENCES affiliates(id) ON DELETE CASCADE, ip_address TEXT, user_agent TEXT, referrer TEXT, clicked_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_clicks_affiliate_id ON affiliate_clicks(affiliate_id); CREATE INDEX idx_clicks_timestamp ON affiliate_clicks(clicked_at); ``` -------------------------------- ### Contribution workflow: create branch, commit, push Source: https://github.com/zak123dsfdf/refearnapp/blob/main/CONTRIBUTING.md These commands are part of the contribution workflow: create a scoped feature branch, commit with a structured prefix, and push to your fork. Use them in sequence when submitting changes. ```bash git checkout -b feature/amazing-feature ``` ```bash git commit -m "feat: add email notifications" ``` ```bash git push origin feature/amazing-feature ``` -------------------------------- ### Create Promotion Code with Affiliate Metadata Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/stripe-coupon-affiliate-tracking.mdx Creates a Stripe promotion code on top of a coupon, embedding affiliate metadata for tracking. The code is human-readable (e.g., 'ZAK20') and includes optional max redemptions. Returns the coupon and promotion code IDs. ```typescript // Step 2: Create a Promotion Code on top of the Coupon // Promotion Codes are what users actually type at checkout const promotionCode = await stripe.promotionCodes.create({ coupon: coupon.id, code, // The human-readable code, e.g., "ZAK20" ...(maxRedemptions ? { max_redemptions: maxRedemptions } : {}), metadata: { affiliate_id: affiliateId, affiliate_slug: affiliateSlug, source: 'refearnapp', }, }); return { couponId: coupon.id, promotionCodeId: promotionCode.id, }; }`} ``` -------------------------------- ### useReferralLink hook to generate referral link Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/how-to-build-a-referral-system-in-react.mdx Generates a referral link using the user's referral slug. Returns null if no slug exists. Uses useMemo to avoid recalculating the link unnecessarily. ```typescript // hooks/useReferralLink.ts import { useMemo } from 'react'; import { useUser } from '@/hooks/useUser'; // Your auth hook export function useReferralLink() { const { user } = useUser(); const referralLink = useMemo(() => { if (!user?.referralSlug) return null; const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? window.location.origin; return `${baseUrl}?ref=${user.referralSlug}`; }, [user?.referralSlug]); return { referralLink }; } ``` -------------------------------- ### Root layout with Suspense wrapper for ReferralTracker Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/how-to-build-a-referral-system-in-react.mdx Mounts the ReferralTracker in the root layout, wrapped in Suspense with fallback={null} to prevent static rendering errors. The fallback ensures no UI change, keeping the tracker invisible to users. ```tsx // app/layout.tsx import { Suspense } from 'react'; import { ReferralTracker } from '@/components/ReferralTracker'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` -------------------------------- ### Run Caddy for Subdomain Proxy Routing Source: https://github.com/zak123dsfdf/refearnapp/blob/main/CONTRIBUTING.md Navigates to the main dashboard container and runs Caddy to map local multitenancy profiles using /etc/hosts layers. Required for subdomain proxy routing. ```bash cd apps/dashboard caddy run ``` -------------------------------- ### Astro project structure Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/README.md Shows the default folder and file layout of an Astro project. ```text / ├── public/ ├── src/ │ └── pages/ │ └── index.astro └── package.json ``` -------------------------------- ### Copy Dashboard Environment Template Source: https://github.com/zak123dsfdf/refearnapp/blob/main/CONTRIBUTING.md Copies the environment template for the main dashboard application. ```bash cp apps/dashboard/.env.example apps/dashboard/.env ``` -------------------------------- ### Database schema for affiliates and conversions Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/self-hosted-affiliate-tracking-software.mdx Defines the SQL schema for the affiliates and affiliate_conversions tables in Postgres. Includes fields for tracking affiliate information, one-time conversions, and commission amounts. ```SQL -- Affiliates CREATE TABLE affiliates ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), slug TEXT UNIQUE NOT NULL, -- The ?ref= value name TEXT NOT NULL, email TEXT UNIQUE NOT NULL, commission_rate NUMERIC(5,4) DEFAULT 0.20, -- 20% created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- One-time conversions CREATE TABLE affiliate_conversions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), affiliate_id UUID NOT NULL REFERENCES affiliates(id), stripe_session_id TEXT UNIQUE NOT NULL, stripe_customer_id TEXT NOT NULL, amount_cents INTEGER NOT NULL, commission_cents INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'pending', -- pending | paid | reversed converted_at TIMESTAMPTZ NOT NULL, paid_at TIMESTAMPTZ ); ``` -------------------------------- ### Create affiliate coupon conversion record Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/stripe-coupon-affiliate-tracking.mdx Creates a new affiliateCouponConversion record after a successful checkout, storing affiliate, coupon, and payment details. Also increments the timesRedeemed counter on the affiliateCouponCode record. ```typescript await db.affiliateCouponConversion.create({ data: { affiliateId, couponCodeId: couponCodeRecord.id, stripeSessionId: session.id, stripeCustomerId: session.customer as string, stripeSubscriptionId: session.subscription as string | null, amountCents: session.amount_total ?? 0, discountAppliedCents, commissionCents, status: 'pending', convertedAt: new Date(), }, }); // Increment the redemption counter on the local record await db.affiliateCouponCode.update({ where: { id: couponCodeRecord.id }, data: { timesRedeemed: { increment: 1 } }, }); ``` -------------------------------- ### Create Stripe coupon with affiliate metadata in TypeScript Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/stripe-coupon-affiliate-tracking.mdx TypeScript function using Stripe SDK to create a coupon with affiliate ID and slug in metadata. Requires percentOff or amountOff, otherwise throws an error. The metadata allows webhook attribution without a DB lookup. ```typescript // lib/stripe/create-affiliate-coupon.ts import Stripe from 'stripe'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2024-11-20', }); interface CreateAffiliateCouponParams { affiliateId: string; affiliateSlug: string; code: string; // e.g., "ZAK20" percentOff?: number; // e.g., 20 for 20% off amountOff?: number; // in cents, e.g., 1000 for $10 off currency?: string; // required if amountOff is set maxRedemptions?: number; // omit for unlimited durationInMonths?: number; // for 'repeating' duration } export async function createAffiliateCoupon( params: CreateAffiliateCouponParams, ): Promise<{ couponId: string; promotionCodeId: string }> { const { affiliateId, affiliateSlug, code, percentOff, amountOff, currency = 'usd', maxRedemptions, durationInMonths, } = params; if (!percentOff && !amountOff) { throw new Error('Either percentOff or amountOff must be provided'); } // Step 1: Create the underlying Stripe Coupon const coupon = await stripe.coupons.create({ ...(percentOff ? { percent_off: percentOff } : {}), ...(amountOff ? { amount_off: amountOff, currency } : {}), duration: durationInMonths ? 'repeating' : 'once', ...(durationInMonths ? { duration_in_months: durationInMonths } : {}), metadata: { affiliate_id: affiliateId, affiliate_slug: affiliateSlug, source: 'refearnapp', }, }); ``` -------------------------------- ### Run Specific Services with pnpm Workspace Filters Source: https://github.com/zak123dsfdf/refearnapp/blob/main/CONTRIBUTING.md Targets specific services using pnpm workspace filters to minimize system resources. Each command runs a different service: the dashboard on port 3000, the tracking worker on port 8787, the payment simulator on port 3001, and the landing page. ```bash # Main UI Dashboard App (Port: 3000) pnpm dev --filter @repo/dashboard # Tracking Cloudflare Worker Engine (Port: 8787) pnpm dev --filter @repo/tracking-worker # Payment Flow Sandbox Simulator (Port: 3001) pnpm dev --filter simulator # Marketing / Landing Page Resource Container pnpm dev --filter @repo/landing-page ``` -------------------------------- ### Middleware for Referral Capture (Basic) Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/affiliate-program-nextjs.mdx Basic middleware that captures the 'ref' query parameter and sets a cookie if not already present. This is the initial, simpler version before adding validation. ```TypeScript export function middleware(request: NextRequest) { const response = NextResponse.next(); const ref = request.nextUrl.searchParams.get('ref'); if (ref && !request.cookies.has('affiliate_ref')) { response.cookies.set('affiliate_ref', ref, { maxAge: 60 * 60 * 24 * 30, sameSite: 'lax', secure: process.env.NODE_ENV === 'production', }); } return response; } export const config = { matcher: ['/((?!_next|api|favicon.ico).*)'], }; ``` -------------------------------- ### ReferralDashboard component with React Query Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/how-to-build-a-referral-system-in-react.mdx Fetches referral statistics from /api/referral/stats and displays them with loading and error states. Uses useQuery with a staleTime of 60 seconds to reduce refetches. The component shows a skeleton loader while loading and an error message if the fetch fails. ```tsx // components/ReferralDashboard.tsx 'use client'; import { useQuery } from '@tanstack/react-query'; interface ReferralStats { totalClicks: number; totalSignups: number; totalConversions: number; pendingCommissionCents: number; paidCommissionCents: number; recentConversions: { id: string; amountCents: number; commissionCents: number; status: string; convertedAt: string; }[]; } async function fetchReferralStats(): Promise { const res = await fetch('/api/referral/stats'); if (!res.ok) throw new Error('Failed to fetch referral stats'); return res.json(); } function formatCents(cents: number): string { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', }).format(cents / 100); } export function ReferralDashboard() { const { data, isLoading, error } = useQuery({ queryKey: ['referral-stats'], queryFn: fetchReferralStats, staleTime: 60_000, }); if (isLoading) { return (
{Array.from({ length: 4 }).map((_, i) => (
))}
); } if (error || !data) { return

Could not load referral stats. Try refreshing.

; } ``` -------------------------------- ### Coolify Environment Variables for RefearnApp Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/opensource-alternative-to-rewardful.mdx Required environment variables for deploying RefearnApp in Coolify. Set NEXT_PUBLIC_APP_URL to your tracking subdomain and DATABASE_URL to your PostgreSQL connection string. ```env NEXT_PUBLIC_APP_URL=https://tracking.yourdomain.com DATABASE_URL=postgresql://user:password@your-postgres-host:5432/refearn ``` -------------------------------- ### SQL Schema for Affiliate Commissions and Indexes Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/self-hosted-affiliate-tracking-software.mdx Defines the affiliate_commissions table and supporting indexes for tracking recurring commissions. Use this schema to store commission events linked to affiliates and Stripe invoices, with indexes on affiliate_id and status for efficient queries. ```sql -- Recurring commission events CREATE TABLE affiliate_commissions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), affiliate_id UUID NOT NULL REFERENCES affiliates(id), stripe_invoice_id TEXT UNIQUE NOT NULL, amount_cents INTEGER NOT NULL, commission_cents INTEGER NOT NULL, type TEXT NOT NULL DEFAULT 'recurring', status TEXT NOT NULL DEFAULT 'pending', paid_at TIMESTAMPTZ NOT NULL ); CREATE INDEX idx_conversions_affiliate ON affiliate_conversions(affiliate_id); CREATE INDEX idx_commissions_affiliate ON affiliate_commissions(affiliate_id); CREATE INDEX idx_conversions_status ON affiliate_conversions(status); ``` -------------------------------- ### Resolve affiliate from checkout session Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/stripe-coupon-affiliate-tracking.mdx Resolves affiliate attribution from a completed checkout session. Checks both total_details (for promotion codes applied at checkout) and session metadata (for coupons applied programmatically). Returns affiliateId, couponCodeRecord, and discountAppliedCents if found, otherwise null. ```typescript /** * Resolves affiliate attribution from a completed checkout session. * Checks both total_details (for promotion codes applied at checkout) * and session metadata (for coupons applied programmatically). */ async function resolveAffiliateFromSession(session: Stripe.Checkout.Session): Promise<{ affiliateId: string; couponCodeRecord: { id: string }; discountAppliedCents: number; } | null> { // Path 1: Promotion code applied by user at checkout // Stripe exposes this in session.total_details.breakdown.discounts const expandedSession = await stripe.checkout.sessions.retrieve(session.id, { expand: ['total_details.breakdown.discounts'], }); const discounts = expandedSession.total_details?.breakdown?.discounts ?? []; for (const discount of discounts) { const promoCodeId = (discount.discount as Stripe.Discount).promotion_code; if (!promoCodeId) continue; // Look up the promotion code in our local DB const couponCodeRecord = await db.affiliateCouponCode.findUnique({ where: { stripePromotionCodeId: typeof promoCodeId === 'string' ? promoCodeId : promoCodeId.id, }, }); if (couponCodeRecord) { return { affiliateId: couponCodeRecord.affiliateId, couponCodeRecord: { id: couponCodeRecord.id }, discountAppliedCents: discount.amount ?? 0, }; } } // Path 2: Coupon applied programmatically (not via promotion code) // Fall back to checking the coupon metadata directly if (session.total_details?.breakdown?.discounts) { for (const discount of session.total_details.breakdown.discounts) { const couponId = (discount.discount as Stripe.Discount).coupon?.id; if (!couponId) continue; const couponCodeRecord = await db.affiliateCouponCode.findFirst({ where: { stripeCouponId: couponId }, }); if (couponCodeRecord) { return { affiliateId: couponCodeRecord.affiliateId, couponCodeRecord: { id: couponCodeRecord.id }, discountAppliedCents: discount.amount ?? 0, }; } } } return null; } ``` -------------------------------- ### Create Stripe Checkout session with affiliate cookie Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/self-hosted-affiliate-tracking-software.mdx Creates a Stripe Checkout session for a subscription, reading the affiliate reference from the request cookie and preparing it as session metadata. Uses the Stripe API version '2024-11-20' and environment variables for the secret key and price ID. The success URL includes the checkout session ID placeholder. ```ts import { NextRequest, NextResponse } from 'next/server'; import Stripe from 'stripe'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2024-11-20', }); export async function POST(request: NextRequest): Promise { // Read the affiliate cookie from the incoming request const affiliateRef = request.cookies.get('affiliate_ref')?.value ?? null; const sessionParams: Stripe.Checkout.SessionCreateParams = { mode: 'subscription', line_items: [{ price: process.env.STRIPE_PRICE_ID!, quantity: 1 }], success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`, }; // ... create checkout session and return redirect } ``` -------------------------------- ### RefTracker component to capture referral param Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/affiliate-program-nextjs.mdx A client component that reads the 'ref' query parameter using useSearchParams and stores it in a cookie named 'affiliate_ref' with a 30-day expiry, sameSite lax, and secure flag in production. Must be wrapped in a Suspense boundary in the root layout to avoid breaking server-side rendering. ```tsx 'use client'; import { useSearchParams } from 'next/navigation'; import { useEffect } from 'react'; import Cookies from 'js-cookie'; export function RefTracker() { const searchParams = useSearchParams(); useEffect(() => { const ref = searchParams.get('ref'); if (ref) { Cookies.set('affiliate_ref', ref, { expires: 30, sameSite: 'lax', secure: process.env.NODE_ENV === 'production', }); } }, [searchParams]); return null; } ``` -------------------------------- ### Referral Stats Dashboard Component Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/how-to-build-a-referral-system-in-react.mdx Renders a dashboard displaying referral statistics: total clicks, signups, conversions, and pending earnings. It highlights the pending earnings stat and lists recent conversions with their status and commission amounts. ```jsx const stats = [ { label: 'Clicks', value: data.totalClicks.toLocaleString() }, { label: 'Signups', value: data.totalSignups.toLocaleString() }, { label: 'Conversions', value: data.totalConversions.toLocaleString() }, { label: 'Pending Earnings', value: formatCents(data.pendingCommissionCents), highlight: true }, ]; return (
{stats.map((stat) => (

{stat.label}

{stat.value}

))}
{data.recentConversions.length > 0 && (

Recent Conversions

{data.recentConversions.map((conversion) => (

{formatCents(conversion.amountCents)} sale

{new Date(conversion.convertedAt).toLocaleDateString()}

+{formatCents(conversion.commissionCents)}

{conversion.status}
))}
)}
); }` } ] } ] }``` ``` -------------------------------- ### Calculate Coupon Discount Amount from Invoice Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/stripe-coupon-affiliate-tracking.mdx Calculates the total discount amount applied to an invoice by summing discount amounts across all line items. Used to determine the discount for affiliate commission calculations. ```TypeScript async function getCouponDiscountAmount(invoice: Stripe.Invoice): Promise { return Math.abs( invoice.lines.data.reduce((total, line) => { const discountAmounts = line.discount_amounts ?? []; return total + discountAmounts.reduce((sum, d) => sum + d.amount, 0); }, 0), ); } ``` -------------------------------- ### Push schema to Postgres database Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/self-hosted-affiliate-tracking-software.mdx Use this command to push the schema layout structures directly to your Postgres database. This is typically done in development or when you need to update the database schema without running migrations. ```bash pnpm db:push ``` -------------------------------- ### Create Single-Use Promotion Code Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/stripe-coupon-affiliate-tracking.mdx Creates a single-use Stripe promotion code with max_redemptions set to 1, enforcing server-side. Includes affiliate metadata for tracking. ```TypeScript const promotionCode = await stripe.promotionCodes.create({ coupon: coupon.id, code: `${affiliateSlug.toUpperCase()}-${crypto.randomUUID().slice(0, 8).toUpperCase()}`, max_redemptions: 1, // Stripe enforces this server-side metadata: { affiliate_id: affiliateId, affiliate_slug: affiliateSlug }, }); ``` -------------------------------- ### Capture affiliate reference with first-click attribution Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/self-hosted-affiliate-tracking-software.mdx Captures the affiliate reference from URL query parameters and stores it in a cookie with a 30-day expiration. Uses first-click attribution, so the cookie is not overwritten if it already exists. The slug is validated against a pattern allowing alphanumeric characters and hyphens. ```typescript // Simplified attribution logic — runs when a visitor lands export function captureAffiliateRef(searchParams: URLSearchParams): void { const ref = searchParams.get('ref'); if (!ref || !isValidSlug(ref)) return; // Only set on first touch — first-click attribution if (document.cookie.includes('affiliate_ref=')) return; const maxAge = 60 * 60 * 24 * 30; // 30 days document.cookie = `affiliate_ref=${encodeURIComponent(ref)}; max-age=${maxAge}; path=/; SameSite=Lax`; } function isValidSlug(slug: string): boolean { return /^[a-zA-Z0-9][a-zA-Z0-9-]{1,39}$/.test(slug); } ``` -------------------------------- ### Commented Hosts Entries Source: https://github.com/zak123dsfdf/refearnapp/blob/main/CONTRIBUTING.md Comment out the hosts entries to access production platforms when Caddy is not running. ```text # 127.0.0.1 refearnapp.com # 127.0.0.1 xyz.refearnapp.com ``` -------------------------------- ### ReferralTracker component with cookie-based attribution Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/how-to-build-a-referral-system-in-react.mdx Tracks referral links by reading the 'ref' query parameter, validating it with a slug pattern, and setting a cookie for 30 days. Uses first-click attribution, so it doesn't overwrite an existing cookie. Must be wrapped in Suspense in the App Router to avoid static rendering errors. ```typescript import { useEffect } from 'react'; import { useSearchParams } from 'next/navigation'; const COOKIE_NAME = 'affiliate_ref'; const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days function isValidSlug(slug: string): boolean { return /^[a-zA-Z0-9][a-zA-Z0-9-]{1,39}$/.test(slug); } function getCookie(name: string): string | null { const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`)); return match ? decodeURIComponent(match[2]) : null; } function setCookie(name: string, value: string, maxAge: number): void { document.cookie = [ `${name}=${encodeURIComponent(value)}`, `max-age=${maxAge}`, 'path=/', 'SameSite=Lax', process.env.NODE_ENV === 'production' ? 'Secure' : '', ] .filter(Boolean) .join('; '); } export function ReferralTracker() { const searchParams = useSearchParams(); useEffect(() => { const ref = searchParams.get('ref'); if (!ref || !isValidSlug(ref)) return; // First-click attribution — don't overwrite if already set if (getCookie(COOKIE_NAME)) return; setCookie(COOKIE_NAME, ref, COOKIE_MAX_AGE); }, [searchParams]); return null; } ``` -------------------------------- ### Stripe Checkout Session Creation (app/api/checkout/route.ts) Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/affiliate-program-nextjs.mdx API route that creates a Stripe Checkout session for a subscription. It reads the affiliate_ref cookie, passes it as metadata to the session, and returns the session URL in the response. ```TypeScript import { NextRequest, NextResponse } from 'next/server'; import Stripe from 'stripe'; const stripe = new Stripe( process.env.STRIPE_SECRET_KEY!, { apiVersion: '2024-04-10', } ); export async function POST(request: NextRequest) { const affiliateRef = request.cookies.get('affiliate_ref')?.value ?? null; const session = await stripe.checkout.sessions.create({ mode: 'subscription', line_items: [ { price: process.env.STRIPE_PRICE_ID!, quantity: 1, }, ], success_url: `${process.env.NEXT_PUBLIC_URL}/success?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`, metadata: { affiliate_ref: affiliateRef ?? '', }, }); return NextResponse.json({ url: session.url, }); } ``` -------------------------------- ### Stripe webhook handler for commission recording Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/self-hosted-affiliate-tracking-software.mdx Handles Stripe webhook events to record affiliate commissions. Verifies the signature, processes checkout.session.completed for initial conversions, and handles invoice.payment_succeeded for recurring commissions. ```TypeScript import { NextRequest, NextResponse } from 'next/server'; import Stripe from 'stripe'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2024-11-20', }); export async function POST(request: NextRequest): Promise { const body = await request.text(); const sig = request.headers.get('stripe-signature'); if (!sig) return NextResponse.json({ error: 'No signature' }, { status: 400 }); let event: Stripe.Event; try { event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!); } catch { return NextResponse.json({ error: 'Invalid signature' }, { status: 400 }); } // Initial conversion if (event.type === 'checkout.session.completed') { const session = event.data.object as Stripe.Checkout.Session; const ref = session.metadata?.affiliate_ref; if (ref && session.amount_total) { await db.affiliateConversion.create({ data: { affiliateSlug: ref, stripeSessionId: session.id, stripeCustomerId: session.customer as string, amountCents: session.amount_total, commissionCents: Math.floor(session.amount_total * 0.2), // 20% commission status: 'pending', convertedAt: new Date(), }, }); } } // Recurring commissions if (event.type === 'invoice.payment_succeeded') { const invoice = event.data.object as Stripe.Invoice; const sub = await stripe.subscriptions.retrieve(invoice.subscription as string); const ref = sub.metadata?.affiliate_ref; if (ref && invoice.amount_paid) { await db.affiliateCommission.create({ data: { affiliateSlug: ref, stripeInvoiceId: invoice.id, amountCents: invoice.amount_paid, commissionCents: Math.floor(invoice.amount_paid * 0.2), type: 'recurring', status: 'pending', paidAt: new Date(), }, }); } } return NextResponse.json({ received: true }); } ``` -------------------------------- ### ReferralLinkCard component to display and copy referral link Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/how-to-build-a-referral-system-in-react.mdx Displays the referral link and provides a copy button. Uses the useReferralLink hook. Shows a 'Copied!' confirmation for 2 seconds after copying. ```tsx // components/ReferralLinkCard.tsx 'use client'; import { useState } from 'react'; import { useReferralLink } from '@/hooks/useReferralLink'; export function ReferralLinkCard() { const { referralLink } = useReferralLink(); const [copied, setCopied] = useState(false); async function handleCopy() { if (!referralLink) return; await navigator.clipboard.writeText(referralLink); setCopied(true); setTimeout(() => setCopied(false), 2000); } if (!referralLink) return null; return (

Your Referral Link

Share this link and earn 20% of every customer you refer — recurring.

{referralLink}
); } ``` -------------------------------- ### generateReferralSlug function using nanoid Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/how-to-build-a-referral-system-in-react.mdx Generates a referral slug. Uses a custom alphabet without ambiguous characters. If a username is provided and valid, it uses a cleaned version of it; otherwise, it falls back to a random ID. ```typescript // lib/referral/generate-slug.ts import { customAlphabet } from 'nanoid'; // Alphanumeric only, no ambiguous characters (0, O, I, l) const generateId = customAlphabet('23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz', 8); export function generateReferralSlug(username?: string): string { if (username) { // Use username if available and valid, fall back to random ID const cleaned = username.toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 20); if (cleaned.length >= 3) return cleaned; } return generateId(); } ``` -------------------------------- ### Attach affiliate reference to Stripe session and subscription Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/self-hosted-affiliate-tracking-software.mdx Attaches the affiliate reference to both the session metadata and subscription data so it is available in webhook events. This is required for tracking both one-time conversions and recurring commissions. ```TypeScript if (affiliateRef) { // Attach to session — readable in checkout.session.completed webhook sessionParams.metadata = { affiliate_ref: affiliateRef }; // Attach to subscription — readable on every invoice for recurring commissions sessionParams.subscription_data = { metadata: { affiliate_ref: affiliateRef }, }; } const session = await stripe.checkout.sessions.create(sessionParams); return NextResponse.json({ url: session.url }); } ``` -------------------------------- ### Full Ref Capture with Validation (middleware.ts) Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/affiliate-program-nextjs.mdx Enhanced middleware that validates the referral parameter using a regex pattern before setting the cookie. It also defines constants for the cookie name and max age, and sets the cookie with additional security options like httpOnly and path. ```TypeScript import { NextRequest, NextResponse } from 'next/server'; const REF_COOKIE = 'affiliate_ref'; const REF_MAX_AGE = 60 * 60 * 24 * 30; function isValidRef(ref: string): boolean { return /^[a-zA-Z0-9-]{3,32}$/.test(ref); } export function middleware(request: NextRequest) { const response = NextResponse.next(); const ref = request.nextUrl.searchParams.get('ref'); if ( ref && isValidRef(ref) && !request.cookies.has(REF_COOKIE) ) { response.cookies.set(REF_COOKIE, ref, { maxAge: REF_MAX_AGE, httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production', path: '/', }); } return response; } ``` -------------------------------- ### Dashboard Redirection URL Source: https://github.com/zak123dsfdf/refearnapp/blob/main/CONTRIBUTING.md Set this environment variable in apps/dashboard/.env to the ngrok tunnel URL for local tracking redirects. ```text NEXT_PUBLIC_REDIRECTION_URL=https://your-tunnel-id.ngrok-free.app ``` -------------------------------- ### Middleware to intercept inbound request edge Source: https://github.com/zak123dsfdf/refearnapp/blob/main/apps/landing-page/src/content/blog/affiliate-program-nextjs.mdx An alternative to the client component approach, using Next.js middleware to intercept the inbound request edge directly. This is Option B for capturing the referral param with zero client JavaScript. ```ts import { NextRequest, NextResponse } from 'next/server'; ```