ShopySeed

Customization Guide

Customize and extend ShopySeed — branding, UI changes, business features, database extensions, and more.

This guide covers how to customize and extend ShopySeed to build your specific SaaS application. From branding and UI changes to adding new features and business logic.

Table of Contents

Branding & Identity

Application Name & Metadata

1. Update Package Information

// package.json
{
  "name": "your-saas-name",
  "description": "Your SaaS description",
  "version": "1.0.0",
  "author": "Your Company",
  "license": "PROPRIETARY"
}

// apps/web/package.json
{
  "name": "your-saas-web",
  "description": "Your SaaS web application"
}

// apps/api/package.json  
{
  "name": "your-saas-api",
  "description": "Your SaaS API server"
}

2. Update Application Metadata

// apps/web/src/app/layout.tsx
export const metadata: Metadata = {
  title: 'Your SaaS Name',
  description: 'Your SaaS description for better productivity',
  keywords: ['saas', 'productivity', 'your-keywords'],
  authors: [{ name: 'Your Company' }],
  openGraph: {
    title: 'Your SaaS Name',
    description: 'Your SaaS description',
    url: 'https://yourdomain.com',
    siteName: 'Your SaaS Name',
    images: [
      {
        url: 'https://yourdomain.com/og-image.jpg',
        width: 1200,
        height: 630,
      },
    ],
  },
  twitter: {
    card: 'summary_large_image',
    title: 'Your SaaS Name',
    description: 'Your SaaS description',
    creator: '@yourtwitterhandle',
    images: ['https://yourdomain.com/twitter-image.jpg'],
  },
};

3. Update API Documentation

// apps/api/src/main.ts
const config = new DocumentBuilder()
  .setTitle('Your SaaS API')
  .setDescription('API documentation for Your SaaS')
  .setVersion('1.0')
  .addBearerAuth()
  .setContact('Your Company', 'https://yourdomain.com', 'api@yourdomain.com')
  .setLicense('Proprietary', 'https://yourdomain.com/license')
  .build();

Logo & Visual Identity

1. Replace Logo Assets

# Replace these files with your brand assets
apps/web/public/logo.svg              # Main logo
apps/web/public/logo-dark.svg         # Dark mode logo  
apps/web/public/favicon.ico           # Favicon
apps/web/public/apple-touch-icon.png  # iOS icon
apps/web/public/og-image.jpg          # OpenGraph image

2. Update Logo Component

// apps/web/src/components/ui/logo.tsx
import Image from 'next/image';
import { useTheme } from 'next-themes';

export function Logo({ className = '', ...props }) {
  const { theme } = useTheme();
  
  return (
    <Image
      src={theme === 'dark' ? '/logo-dark.svg' : '/logo.svg'}
      alt="Your SaaS Name"
      width={120}
      height={40}
      className={className}
      {...props}
    />
  );
}

3. Update Brand Colors

/* apps/web/src/app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  :root {
    /* Update these with your brand colors */
    --background: 0 0% 100%;
    --foreground: 222.2 84% 4.9%;
    --primary: 221.2 83.2% 53.3%;      /* Your primary brand color */
    --primary-foreground: 210 40% 98%;
    --secondary: 210 40% 96%;
    --accent: 210 40% 96%;
    /* ... rest of color variables */
  }
  
  .dark {
    /* Dark mode colors */
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;
    /* ... */
  }
}

/* Custom brand utilities */
.text-brand {
  @apply text-primary;
}

.bg-brand {
  @apply bg-primary;
}

.border-brand {
  @apply border-primary;
}

UI Customization

Theme Customization

1. Tailwind Configuration

// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
    './src/components/**/*.{js,ts,jsx,tsx,mdx}',
    './src/app/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  theme: {
    extend: {
      colors: {
        // Your brand color palette
        brand: {
          50: '#eff6ff',
          100: '#dbeafe', 
          200: '#bfdbfe',
          300: '#93c5fd',
          400: '#60a5fa',
          500: '#3b82f6',   // Primary brand color
          600: '#2563eb',
          700: '#1d4ed8',
          800: '#1e40af',
          900: '#1e3a8a',
        },
        // Custom semantic colors
        success: '#10b981',
        warning: '#f59e0b',
        error: '#ef4444',
      },
      fontFamily: {
        // Custom fonts
        sans: ['Inter', 'system-ui', 'sans-serif'],
        heading: ['Poppins', 'system-ui', 'sans-serif'],
      },
      animation: {
        // Custom animations
        'slide-in': 'slideIn 0.3s ease-in-out',
        'fade-in': 'fadeIn 0.5s ease-in-out',
      },
    },
  },
  plugins: [
    require('@tailwindcss/forms'),
    require('@tailwindcss/typography'),
  ],
};

2. Component Theme Customization

// apps/web/src/lib/theme.ts
export const buttonVariants = cva(
  "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",
  {
    variants: {
      variant: {
        default: "bg-brand-500 text-white hover:bg-brand-600",
        destructive: "bg-red-500 text-white hover:bg-red-600",
        outline: "border border-brand-300 bg-transparent hover:bg-brand-50",
        secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200",
        ghost: "hover:bg-brand-50 hover:text-brand-900",
        link: "text-brand-500 underline-offset-4 hover:underline",
      },
      size: {
        default: "h-10 py-2 px-4",
        sm: "h-9 px-3 rounded-md",
        lg: "h-11 px-8 rounded-md",
        icon: "h-10 w-10",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
);

Layout Customization

1. Navigation Structure

// apps/web/src/components/layout/navigation.tsx
const navigationItems = [
  {
    title: 'Dashboard',
    href: '/dashboard',
    icon: Home,
    description: 'Overview of your account'
  },
  {
    title: 'Your Feature',          // Add your custom features
    href: '/dashboard/your-feature',
    icon: YourIcon,
    description: 'Description of your feature'
  },
  {
    title: 'Analytics',             // Example custom feature
    href: '/dashboard/analytics',
    icon: BarChart3,
    description: 'View your analytics'
  },
  // ... existing items
];

2. Dashboard Layout

// apps/web/src/app/dashboard/layout.tsx
import { Navigation } from '@/components/layout/navigation';
import { Header } from '@/components/layout/header';

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="min-h-screen bg-gray-50">
      <Header />
      <div className="flex">
        <aside className="w-64 min-h-screen bg-white shadow-sm">
          <Navigation />
        </aside>
        <main className="flex-1 p-6">
          {children}
        </main>
      </div>
    </div>
  );
}

Adding Business Features

Example: Adding a "Projects" Feature

1. Database Schema

// apps/api/prisma/schema.prisma
model Project {
  id          String   @id @default(uuid())
  name        String
  description String?
  status      ProjectStatus @default(ACTIVE)
  ownerId     String
  teamId      String?
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  // Relations
  owner       User     @relation(fields: [ownerId], references: [id], onDelete: Cascade)
  team        Team?    @relation(fields: [teamId], references: [id])
  tasks       Task[]
  
  @@map("projects")
  // This table will be created in each tenant schema
}

enum ProjectStatus {
  ACTIVE
  COMPLETED
  ARCHIVED
  ON_HOLD
}

model Task {
  id          String     @id @default(uuid())
  title       String
  description String?
  status      TaskStatus @default(TODO)
  priority    Priority   @default(MEDIUM)
  projectId   String
  assigneeId  String?
  createdAt   DateTime   @default(now())
  updatedAt   DateTime   @updatedAt

  // Relations
  project     Project    @relation(fields: [projectId], references: [id], onDelete: Cascade)
  assignee    User?      @relation(fields: [assigneeId], references: [id])
  
  @@map("tasks")
}

enum TaskStatus {
  TODO
  IN_PROGRESS
  REVIEW
  DONE
}

enum Priority {
  LOW
  MEDIUM
  HIGH
  URGENT
}

2. Backend API Module

// apps/api/src/projects/projects.controller.ts
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { OrganizationGuard } from '../common/guards/organization.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { CurrentOrganization } from '../common/decorators/current-organization.decorator';
import { ProjectsService } from './projects.service';
import { CreateProjectDto, UpdateProjectDto, ProjectListDto } from './dto';

@ApiTags('projects')
@Controller('projects')
@UseGuards(JwtAuthGuard, OrganizationGuard)
@ApiBearerAuth()
export class ProjectsController {
  constructor(private readonly projectsService: ProjectsService) {}

  @Post()
  @ApiOperation({ summary: 'Create a new project' })
  async create(
    @CurrentUser('id') userId: string,
    @CurrentOrganization() organizationId: string,
    @Body() createProjectDto: CreateProjectDto,
  ) {
    return this.projectsService.create(userId, organizationId, createProjectDto);
  }

  @Get()
  @ApiOperation({ summary: 'Get all projects for organization' })
  async findAll(
    @CurrentOrganization() organizationId: string,
    @Query() query: ProjectListDto,
  ) {
    return this.projectsService.findAll(organizationId, query);
  }

  @Get(':id')
  @ApiOperation({ summary: 'Get project by ID' })
  async findOne(
    @Param('id') id: string,
    @CurrentOrganization() organizationId: string,
  ) {
    return this.projectsService.findOne(id, organizationId);
  }

  @Patch(':id')
  @ApiOperation({ summary: 'Update project' })
  async update(
    @Param('id') id: string,
    @CurrentUser('id') userId: string,
    @CurrentOrganization() organizationId: string,
    @Body() updateProjectDto: UpdateProjectDto,
  ) {
    return this.projectsService.update(id, userId, organizationId, updateProjectDto);
  }

  @Delete(':id')
  @ApiOperation({ summary: 'Delete project' })
  async remove(
    @Param('id') id: string,
    @CurrentUser('id') userId: string,
    @CurrentOrganization() organizationId: string,
  ) {
    return this.projectsService.remove(id, userId, organizationId);
  }
}

3. Frontend Pages

// apps/web/src/app/dashboard/projects/page.tsx
'use client';

import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { CreateProjectDialog } from '@/components/projects/create-project-dialog';
import { ProjectCard } from '@/components/projects/project-card';
import { useProjects } from '@/hooks/use-projects';
import { Plus, Folder, Clock, CheckCircle } from 'lucide-react';

export default function ProjectsPage() {
  const { projects, loading, createProject, deleteProject } = useProjects();
  const [showCreateDialog, setShowCreateDialog] = useState(false);

  const stats = {
    total: projects.length,
    active: projects.filter(p => p.status === 'ACTIVE').length,
    completed: projects.filter(p => p.status === 'COMPLETED').length,
  };

  return (
    <div className="space-y-6">
      {/* Header */}
      <div className="flex justify-between items-center">
        <div>
          <h1 className="text-3xl font-bold tracking-tight">Projects</h1>
          <p className="text-muted-foreground">
            Manage and track your projects
          </p>
        </div>
        <Button onClick={() => setShowCreateDialog(true)}>
          <Plus className="h-4 w-4 mr-2" />
          New Project
        </Button>
      </div>

      {/* Stats */}
      <div className="grid gap-4 md:grid-cols-3">
        <Card>
          <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
            <CardTitle className="text-sm font-medium">Total Projects</CardTitle>
            <Folder className="h-4 w-4 text-muted-foreground" />
          </CardHeader>
          <CardContent>
            <div className="text-2xl font-bold">{stats.total}</div>
          </CardContent>
        </Card>
        <Card>
          <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
            <CardTitle className="text-sm font-medium">Active</CardTitle>
            <Clock className="h-4 w-4 text-muted-foreground" />
          </CardHeader>
          <CardContent>
            <div className="text-2xl font-bold">{stats.active}</div>
          </CardContent>
        </Card>
        <Card>
          <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
            <CardTitle className="text-sm font-medium">Completed</CardTitle>
            <CheckCircle className="h-4 w-4 text-muted-foreground" />
          </CardHeader>
          <CardContent>
            <div className="text-2xl font-bold">{stats.completed}</div>
          </CardContent>
        </Card>
      </div>

      {/* Projects Grid */}
      <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
        {projects.map((project) => (
          <ProjectCard
            key={project.id}
            project={project}
            onDelete={() => deleteProject(project.id)}
          />
        ))}
      </div>

      {/* Create Dialog */}
      <CreateProjectDialog
        open={showCreateDialog}
        onOpenChange={setShowCreateDialog}
        onSubmit={createProject}
      />
    </div>
  );
}

Database Schema Extensions

Adding Tenant-Scoped Tables

When adding new features that should be isolated per organization:

1. Multi-Tenant Table Design

// Any table that should be tenant-specific
model YourCustomTable {
  id             String   @id @default(uuid())
  // Always include organizationId for tenant isolation
  organizationId String   
  name           String
  data           Json?    // Flexible data storage
  createdAt      DateTime @default(now())
  updatedAt      DateTime @updatedAt

  // Relation to organization (in public schema)
  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)

  @@map("your_custom_table")
  // This will be created in tenant schemas
}

2. Migration Strategy

# Create migration with descriptive name
cd apps/api
npx prisma migrate dev --create-only --name add_your_feature

# Review the generated SQL before applying
cat prisma/migrations/*_add_your_feature/migration.sql

# Apply if safe
npx prisma migrate dev

3. Tenant Schema Management

// apps/api/src/common/services/tenant.service.ts
@Injectable()
export class TenantService {
  constructor(private prisma: PrismaService) {}

  async createTenantSchema(organizationId: string): Promise<void> {
    const schemaName = `tenant_${organizationId.replace('-', '')}`;
    
    // Create schema
    await this.prisma.$executeRaw`CREATE SCHEMA IF NOT EXISTS ${Prisma.raw(schemaName)}`;
    
    // Create tenant-specific tables
    await this.prisma.$executeRaw`
      CREATE TABLE IF NOT EXISTS ${Prisma.raw(schemaName)}.your_custom_table (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        organization_id UUID NOT NULL,
        name VARCHAR(255) NOT NULL,
        data JSONB,
        created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
        updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()
      )
    `;
    
    // Add indexes
    await this.prisma.$executeRaw`
      CREATE INDEX IF NOT EXISTS idx_your_custom_table_org_id 
      ON ${Prisma.raw(schemaName)}.your_custom_table(organization_id)
    `;
  }
}

Adding System-Wide Tables

For features that span across all tenants (like admin features):

// These stay in the public schema
model SystemWideFeature {
  id          String   @id @default(uuid())
  name        String
  description String?
  enabled     Boolean  @default(true)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  @@map("system_wide_features")
}

API Customization

Adding Custom Endpoints

1. Custom Business Logic Endpoint

// apps/api/src/analytics/analytics.controller.ts
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { OrganizationGuard } from '../common/guards/organization.guard';
import { CurrentOrganization } from '../common/decorators/current-organization.decorator';
import { AnalyticsService } from './analytics.service';

@ApiTags('analytics')
@Controller('analytics')
@UseGuards(JwtAuthGuard, OrganizationGuard)
@ApiBearerAuth()
export class AnalyticsController {
  constructor(private readonly analyticsService: AnalyticsService) {}

  @Get('dashboard')
  @ApiOperation({ summary: 'Get dashboard analytics' })
  async getDashboardAnalytics(
    @CurrentOrganization() organizationId: string,
    @Query('period') period: string = '30d',
  ) {
    return this.analyticsService.getDashboardAnalytics(organizationId, period);
  }

  @Get('revenue')
  @ApiOperation({ summary: 'Get revenue analytics' })
  async getRevenueAnalytics(
    @CurrentOrganization() organizationId: string,
    @Query('from') from?: string,
    @Query('to') to?: string,
  ) {
    return this.analyticsService.getRevenueAnalytics(organizationId, { from, to });
  }
}

2. Custom Middleware

// apps/api/src/common/middleware/audit.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { PrismaService } from '../prisma/prisma.service';

@Injectable()
export class AuditMiddleware implements NestMiddleware {
  constructor(private prisma: PrismaService) {}

  async use(req: Request, res: Response, next: NextFunction) {
    // Log API calls for audit purposes
    if (req.method !== 'GET' && req.user) {
      await this.prisma.auditLog.create({
        data: {
          userId: req.user.id,
          organizationId: req.headers['x-organization-id'] as string,
          action: `${req.method} ${req.path}`,
          ipAddress: req.ip,
          userAgent: req.headers['user-agent'],
          requestBody: req.method === 'POST' ? req.body : null,
        },
      });
    }
    next();
  }
}

Custom Validation & DTOs

// apps/api/src/common/validators/custom.validator.ts
import { registerDecorator, ValidationOptions, ValidationArguments } from 'class-validator';

export function IsSlugValid(validationOptions?: ValidationOptions) {
  return function (object: Object, propertyName: string) {
    registerDecorator({
      name: 'isSlugValid',
      target: object.constructor,
      propertyName: propertyName,
      options: validationOptions,
      validator: {
        validate(value: any, args: ValidationArguments) {
          return typeof value === 'string' && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
        },
        defaultMessage(args: ValidationArguments) {
          return 'Slug must contain only lowercase letters, numbers, and hyphens';
        },
      },
    });
  };
}

// Usage in DTO
export class CreateProjectDto {
  @IsNotEmpty()
  @IsString()
  @Length(1, 100)
  name: string;

  @IsOptional()
  @IsString()
  @IsSlugValid()
  slug?: string;

  @IsOptional()
  @IsString()
  @Length(0, 500)
  description?: string;
}

Frontend Customization

Adding Custom Pages

1. New Feature Page

// apps/web/src/app/dashboard/analytics/page.tsx
'use client';

import { useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { RevenueChart } from '@/components/analytics/revenue-chart';
import { UserGrowthChart } from '@/components/analytics/user-growth-chart';
import { TopProductsTable } from '@/components/analytics/top-products-table';
import { useAnalytics } from '@/hooks/use-analytics';

export default function AnalyticsPage() {
  const [period, setPeriod] = useState('30d');
  const { dashboardData, revenueData, loading } = useAnalytics(period);

  return (
    <div className="space-y-6">
      <div className="flex justify-between items-center">
        <div>
          <h1 className="text-3xl font-bold tracking-tight">Analytics</h1>
          <p className="text-muted-foreground">
            Track your business performance and growth
          </p>
        </div>
        <Select value={period} onValueChange={setPeriod}>
          <SelectTrigger className="w-32">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            <SelectItem value="7d">Last 7 days</SelectItem>
            <SelectItem value="30d">Last 30 days</SelectItem>
            <SelectItem value="90d">Last 90 days</SelectItem>
            <SelectItem value="1y">Last year</SelectItem>
          </SelectContent>
        </Select>
      </div>

      {/* KPI Cards */}
      <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
        <Card>
          <CardHeader className="pb-2">
            <CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
          </CardHeader>
          <CardContent>
            <div className="text-2xl font-bold">${dashboardData?.revenue?.toLocaleString()}</div>
            <p className="text-xs text-muted-foreground">+12% from last month</p>
          </CardContent>
        </Card>
        <Card>
          <CardHeader className="pb-2">
            <CardTitle className="text-sm font-medium">Active Users</CardTitle>
          </CardHeader>
          <CardContent>
            <div className="text-2xl font-bold">{dashboardData?.activeUsers?.toLocaleString()}</div>
            <p className="text-xs text-muted-foreground">+5% from last month</p>
          </CardContent>
        </Card>
        <Card>
          <CardHeader className="pb-2">
            <CardTitle className="text-sm font-medium">Conversion Rate</CardTitle>
          </CardHeader>
          <CardContent>
            <div className="text-2xl font-bold">{dashboardData?.conversionRate}%</div>
            <p className="text-xs text-muted-foreground">+0.5% from last month</p>
          </CardContent>
        </Card>
        <Card>
          <CardHeader className="pb-2">
            <CardTitle className="text-sm font-medium">Churn Rate</CardTitle>
          </CardHeader>
          <CardContent>
            <div className="text-2xl font-bold">{dashboardData?.churnRate}%</div>
            <p className="text-xs text-muted-foreground">-0.8% from last month</p>
          </CardContent>
        </Card>
      </div>

      {/* Charts */}
      <Tabs defaultValue="revenue" className="space-y-4">
        <TabsList>
          <TabsTrigger value="revenue">Revenue</TabsTrigger>
          <TabsTrigger value="users">User Growth</TabsTrigger>
          <TabsTrigger value="products">Top Products</TabsTrigger>
        </TabsList>
        <TabsContent value="revenue">
          <Card>
            <CardHeader>
              <CardTitle>Revenue Overview</CardTitle>
            </CardHeader>
            <CardContent>
              <RevenueChart data={revenueData} />
            </CardContent>
          </Card>
        </TabsContent>
        <TabsContent value="users">
          <Card>
            <CardHeader>
              <CardTitle>User Growth</CardTitle>
            </CardHeader>
            <CardContent>
              <UserGrowthChart period={period} />
            </CardContent>
          </Card>
        </TabsContent>
        <TabsContent value="products">
          <TopProductsTable period={period} />
        </TabsContent>
      </Tabs>
    </div>
  );
}

2. Custom Hooks

// apps/web/src/hooks/use-analytics.ts
import { useState, useEffect } from 'react';
import { useOrganization } from '@/contexts/organization-context';
import { analyticsApi } from '@/lib/api/analytics';

interface AnalyticsData {
  revenue: number;
  activeUsers: number;
  conversionRate: number;
  churnRate: number;
}

interface RevenueData {
  date: string;
  revenue: number;
  subscriptions: number;
}

export function useAnalytics(period: string) {
  const { currentOrganization } = useOrganization();
  const [dashboardData, setDashboardData] = useState<AnalyticsData | null>(null);
  const [revenueData, setRevenueData] = useState<RevenueData[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    async function fetchAnalytics() {
      if (!currentOrganization?.id) return;

      try {
        setLoading(true);
        const [dashboard, revenue] = await Promise.all([
          analyticsApi.getDashboard(period),
          analyticsApi.getRevenue(period),
        ]);

        setDashboardData(dashboard);
        setRevenueData(revenue);
      } catch (error) {
        console.error('Failed to fetch analytics:', error);
      } finally {
        setLoading(false);
      }
    }

    fetchAnalytics();
  }, [currentOrganization?.id, period]);

  return {
    dashboardData,
    revenueData,
    loading,
  };
}

Custom Components

// apps/web/src/components/ui/data-table.tsx
'use client';

import {
  ColumnDef,
  flexRender,
  getCoreRowModel,
  useReactTable,
  getPaginationRowModel,
  getSortedRowModel,
  getFilteredRowModel,
  SortingState,
  ColumnFiltersState,
} from '@tanstack/react-table';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';

interface DataTableProps<TData, TValue> {
  columns: ColumnDef<TData, TValue>[];
  data: TData[];
  searchKey?: string;
  searchPlaceholder?: string;
}

export function DataTable<TData, TValue>({
  columns,
  data,
  searchKey,
  searchPlaceholder = 'Filter...',
}: DataTableProps<TData, TValue>) {
  const [sorting, setSorting] = useState<SortingState>([]);
  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);

  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    onSortingChange: setSorting,
    getSortedRowModel: getSortedRowModel(),
    onColumnFiltersChange: setColumnFilters,
    getFilteredRowModel: getFilteredRowModel(),
    state: {
      sorting,
      columnFilters,
    },
  });

  return (
    <div className="space-y-4">
      {searchKey && (
        <div className="flex items-center py-4">
          <Input
            placeholder={searchPlaceholder}
            value={(table.getColumn(searchKey)?.getFilterValue() as string) ?? ''}
            onChange={(event) =>
              table.getColumn(searchKey)?.setFilterValue(event.target.value)
            }
            className="max-w-sm"
          />
        </div>
      )}
      <div className="rounded-md border">
        <Table>
          <TableHeader>
            {table.getHeaderGroups().map((headerGroup) => (
              <TableRow key={headerGroup.id}>
                {headerGroup.headers.map((header) => {
                  return (
                    <TableHead key={header.id}>
                      {header.isPlaceholder
                        ? null
                        : flexRender(
                            header.column.columnDef.header,
                            header.getContext()
                          )}
                    </TableHead>
                  );
                })}
              </TableRow>
            ))}
          </TableHeader>
          <TableBody>
            {table.getRowModel().rows?.length ? (
              table.getRowModel().rows.map((row) => (
                <TableRow
                  key={row.id}
                  data-state={row.getIsSelected() && 'selected'}
                >
                  {row.getVisibleCells().map((cell) => (
                    <TableCell key={cell.id}>
                      {flexRender(cell.column.columnDef.cell, cell.getContext())}
                    </TableCell>
                  ))}
                </TableRow>
              ))
            ) : (
              <TableRow>
                <TableCell colSpan={columns.length} className="h-24 text-center">
                  No results.
                </TableCell>
              </TableRow>
            )}
          </TableBody>
        </Table>
      </div>
      <div className="flex items-center justify-end space-x-2">
        <div className="flex-1 text-sm text-muted-foreground">
          {table.getFilteredSelectedRowModel().rows.length} of{' '}
          {table.getFilteredRowModel().rows.length} row(s) selected.
        </div>
        <Button
          variant="outline"
          size="sm"
          onClick={() => table.previousPage()}
          disabled={!table.getCanPreviousPage()}
        >
          Previous
        </Button>
        <Button
          variant="outline"
          size="sm"
          onClick={() => table.nextPage()}
          disabled={!table.getCanNextPage()}
        >
          Next
        </Button>
      </div>
    </div>
  );
}

Authentication Providers

Adding OAuth Providers

1. Backend OAuth Configuration

// apps/api/src/auth/strategies/twitter.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-twitter';
import { ConfigService } from '@nestjs/config';
import { AuthService } from '../auth.service';

@Injectable()
export class TwitterStrategy extends PassportStrategy(Strategy, 'twitter') {
  constructor(
    private configService: ConfigService,
    private authService: AuthService,
  ) {
    super({
      consumerKey: configService.get('TWITTER_CONSUMER_KEY'),
      consumerSecret: configService.get('TWITTER_CONSUMER_SECRET'),
      callbackURL: configService.get('TWITTER_CALLBACK_URL'),
      includeEmail: true,
    });
  }

  async validate(
    accessToken: string,
    refreshToken: string,
    profile: any,
  ): Promise<any> {
    return this.authService.validateOAuthUser({
      provider: 'twitter',
      providerId: profile.id,
      email: profile.emails?.[0]?.value,
      name: profile.displayName,
      avatarUrl: profile.photos?.[0]?.value,
    });
  }
}

2. OAuth Routes

// apps/api/src/auth/auth.controller.ts (add to existing)
@Get('oauth/twitter')
@UseGuards(AuthGuard('twitter'))
@ApiOperation({ summary: 'Initiate Twitter OAuth' })
async twitterOAuth() {
  // Initiates Twitter OAuth flow
}

@Get('oauth/twitter/callback')
@UseGuards(AuthGuard('twitter'))
@ApiOperation({ summary: 'Handle Twitter OAuth callback' })
async twitterOAuthCallback(@Req() req: any, @Res() res: any): Promise<void> {
  const result = await this.authService.handleOAuthCallback(req.user);
  
  // Redirect to frontend with tokens
  const redirectUrl = `${this.configService.get('FRONTEND_URL')}/auth/callback?token=${result.accessToken}&refresh=${result.refreshToken}`;
  res.redirect(redirectUrl);
}

3. Frontend OAuth Button

// apps/web/src/components/auth/oauth-buttons.tsx
import { Button } from '@/components/ui/button';
import { Icons } from '@/components/icons';

interface OAuthButtonsProps {
  disabled?: boolean;
}

export function OAuthButtons({ disabled }: OAuthButtonsProps) {
  const handleOAuth = (provider: string) => {
    window.location.href = `${process.env.NEXT_PUBLIC_API_URL}/auth/oauth/${provider}`;
  };

  return (
    <div className="grid grid-cols-2 gap-2">
      <Button
        variant="outline"
        onClick={() => handleOAuth('google')}
        disabled={disabled}
        className="w-full"
      >
        <Icons.google className="mr-2 h-4 w-4" />
        Google
      </Button>
      <Button
        variant="outline"
        onClick={() => handleOAuth('github')}
        disabled={disabled}
        className="w-full"
      >
        <Icons.github className="mr-2 h-4 w-4" />
        GitHub
      </Button>
      <Button
        variant="outline"
        onClick={() => handleOAuth('twitter')}
        disabled={disabled}
        className="w-full"
      >
        <Icons.twitter className="mr-2 h-4 w-4" />
        Twitter
      </Button>
      <Button
        variant="outline"
        onClick={() => handleOAuth('linkedin')}
        disabled={disabled}
        className="w-full"
      >
        <Icons.linkedin className="mr-2 h-4 w-4" />
        LinkedIn
      </Button>
    </div>
  );
}

Billing Plans & Features

Customizing Subscription Plans

1. Update Plan Configuration

// apps/api/src/billing/config/plans.ts
export const PLANS = {
  free: {
    name: 'Free',
    stripePriceId: null,
    price: null,
    limits: {
      users: 2,
      projects: 3,
      storage: 1024 * 1024 * 1024, // 1GB in bytes
      apiCalls: 1000,
      features: ['basic_support'],
    },
    features: [
      'Up to 2 team members',
      'Up to 3 projects',
      '1GB storage',
      '1,000 API calls/month',
      'Basic support',
    ],
  },
  starter: {
    name: 'Starter',
    stripePriceId: 'price_1234567890',
    price: 29,
    limits: {
      users: 10,
      projects: 25,
      storage: 10 * 1024 * 1024 * 1024, // 10GB
      apiCalls: 10000,
      features: ['priority_support', 'advanced_analytics'],
    },
    features: [
      'Up to 10 team members',
      'Up to 25 projects',
      '10GB storage',
      '10,000 API calls/month',
      'Priority support',
      'Advanced analytics',
    ],
  },
  pro: {
    name: 'Pro',
    stripePriceId: 'price_0987654321',
    price: 99,
    limits: {
      users: 50,
      projects: -1, // Unlimited
      storage: 100 * 1024 * 1024 * 1024, // 100GB
      apiCalls: 100000,
      features: ['priority_support', 'advanced_analytics', 'custom_integrations', 'api_access'],
    },
    features: [
      'Up to 50 team members',
      'Unlimited projects',
      '100GB storage',
      '100,000 API calls/month',
      'Priority support',
      'Advanced analytics',
      'Custom integrations',
      'API access',
    ],
  },
  enterprise: {
    name: 'Enterprise',
    stripePriceId: 'price_enterprise',
    price: null, // Contact sales
    limits: {
      users: -1, // Unlimited
      projects: -1,
      storage: -1,
      apiCalls: -1,
      features: ['all'],
    },
    features: [
      'Unlimited everything',
      'Dedicated support',
      'Custom features',
      'SLA guarantee',
      'On-premise option',
    ],
  },
};

2. Feature Gates

// apps/api/src/common/guards/feature.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { BillingService } from '../../billing/billing.service';

@Injectable()
export class FeatureGuard implements CanActivate {
  constructor(
    private reflector: Reflector,
    private billingService: BillingService,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const requiredFeature = this.reflector.get<string>('feature', context.getHandler());
    if (!requiredFeature) return true;

    const request = context.switchToHttp().getRequest();
    const organizationId = request.headers['x-organization-id'];

    const hasFeature = await this.billingService.hasFeature(organizationId, requiredFeature);
    
    if (!hasFeature) {
      throw new ForbiddenException(`This feature requires ${requiredFeature} plan or higher`);
    }

    return true;
  }
}

// Usage decorator
export const RequiresFeature = (feature: string) => SetMetadata('feature', feature);

3. Usage in Controllers

// apps/api/src/analytics/analytics.controller.ts
@Get('advanced')
@UseGuards(JwtAuthGuard, OrganizationGuard, FeatureGuard)
@RequiresFeature('advanced_analytics')
@ApiOperation({ summary: 'Get advanced analytics (Pro+ only)' })
async getAdvancedAnalytics(@CurrentOrganization() organizationId: string) {
  return this.analyticsService.getAdvancedAnalytics(organizationId);
}

Email Templates

Custom Email Templates

1. Template Structure

// apps/api/src/email/templates/welcome.template.ts
export interface WelcomeEmailData {
  userName: string;
  verificationUrl: string;
  supportUrl: string;
}

export const welcomeTemplate = (data: WelcomeEmailData) => ({
  subject: `Welcome to ${process.env.APP_NAME}! 🎉`,
  html: `
    <!DOCTYPE html>
    <html>
      <head>
        <meta charset="utf-8">
        <title>Welcome</title>
        <style>
          /* Your email styles */
          .container { max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif; }
          .header { background: #3b82f6; color: white; padding: 20px; text-align: center; }
          .content { padding: 20px; }
          .button { display: inline-block; background: #3b82f6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; }
        </style>
      </head>
      <body>
        <div class="container">
          <div class="header">
            <h1>Welcome to ${process.env.APP_NAME}!</h1>
          </div>
          <div class="content">
            <p>Hi ${data.userName},</p>
            <p>Welcome to ${process.env.APP_NAME}! We're excited to have you on board.</p>
            <p>To get started, please verify your email address:</p>
            <p>
              <a href="${data.verificationUrl}" class="button">Verify Email</a>
            </p>
            <p>If you have any questions, feel free to reach out to our support team.</p>
            <p>Best regards,<br>The ${process.env.APP_NAME} Team</p>
          </div>
        </div>
      </body>
    </html>
  `,
  text: `
    Welcome to ${process.env.APP_NAME}!
    
    Hi ${data.userName},
    
    Welcome to ${process.env.APP_NAME}! We're excited to have you on board.
    
    To get started, please verify your email address by visiting: ${data.verificationUrl}
    
    If you have any questions, feel free to reach out to our support team.
    
    Best regards,
    The ${process.env.APP_NAME} Team
  `,
});

2. Email Service

// apps/api/src/email/email.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as nodemailer from 'nodemailer';
import { welcomeTemplate, WelcomeEmailData } from './templates/welcome.template';

@Injectable()
export class EmailService {
  private transporter: nodemailer.Transporter;

  constructor(private configService: ConfigService) {
    this.transporter = nodemailer.createTransporter({
      host: this.configService.get('EMAIL_HOST'),
      port: this.configService.get('EMAIL_PORT'),
      secure: false,
      auth: {
        user: this.configService.get('EMAIL_USER'),
        pass: this.configService.get('EMAIL_PASSWORD'),
      },
    });
  }

  async sendWelcomeEmail(to: string, data: WelcomeEmailData): Promise<void> {
    const template = welcomeTemplate(data);
    
    await this.transporter.sendMail({
      from: this.configService.get('EMAIL_FROM'),
      to,
      subject: template.subject,
      html: template.html,
      text: template.text,
    });
  }

  async sendCustomEmail(
    to: string,
    subject: string,
    html: string,
    text?: string,
  ): Promise<void> {
    await this.transporter.sendMail({
      from: this.configService.get('EMAIL_FROM'),
      to,
      subject,
      html,
      text: text || html.replace(/<[^>]*>/g, ''), // Strip HTML for text version
    });
  }
}

File Uploads & Storage

Adding File Upload Capability

1. Backend Upload Handler

// apps/api/src/uploads/uploads.controller.ts
import {
  Controller,
  Post,
  UseInterceptors,
  UploadedFile,
  UseGuards,
  BadRequestException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiConsumes, ApiBody, ApiBearerAuth } from '@nestjs/swagger';
import { Express } from 'express';
import { diskStorage } from 'multer';
import { extname } from 'path';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';

@ApiTags('uploads')
@Controller('uploads')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class UploadsController {
  @Post('avatar')
  @UseInterceptors(FileInterceptor('file', {
    storage: diskStorage({
      destination: './uploads/avatars',
      filename: (req, file, cb) => {
        const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
        cb(null, `avatar-${uniqueSuffix}${extname(file.originalname)}`);
      },
    }),
    fileFilter: (req, file, cb) => {
      if (!file.originalname.match(/\.(jpg|jpeg|png|gif)$/)) {
        return cb(new BadRequestException('Only image files are allowed!'), false);
      }
      cb(null, true);
    },
    limits: {
      fileSize: 5 * 1024 * 1024, // 5MB
    },
  }))
  @ApiConsumes('multipart/form-data')
  @ApiBody({
    schema: {
      type: 'object',
      properties: {
        file: {
          type: 'string',
          format: 'binary',
        },
      },
    },
  })
  async uploadAvatar(
    @UploadedFile() file: Express.Multer.File,
    @CurrentUser('id') userId: string,
  ) {
    if (!file) {
      throw new BadRequestException('No file uploaded');
    }

    const fileUrl = `/uploads/avatars/${file.filename}`;
    
    // Update user avatar in database
    await this.usersService.updateAvatar(userId, fileUrl);

    return {
      filename: file.filename,
      url: fileUrl,
      size: file.size,
    };
  }
}

2. S3 Integration (Production)

// apps/api/src/uploads/s3.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as AWS from 'aws-sdk';

@Injectable()
export class S3Service {
  private s3: AWS.S3;

  constructor(private configService: ConfigService) {
    this.s3 = new AWS.S3({
      accessKeyId: this.configService.get('AWS_ACCESS_KEY_ID'),
      secretAccessKey: this.configService.get('AWS_SECRET_ACCESS_KEY'),
      region: this.configService.get('AWS_REGION'),
    });
  }

  async uploadFile(file: Express.Multer.File, path: string): Promise<string> {
    const uploadParams = {
      Bucket: this.configService.get('AWS_S3_BUCKET'),
      Key: `${path}/${Date.now()}-${file.originalname}`,
      Body: file.buffer,
      ContentType: file.mimetype,
      ACL: 'public-read',
    };

    const result = await this.s3.upload(uploadParams).promise();
    return result.Location;
  }

  async deleteFile(key: string): Promise<void> {
    const deleteParams = {
      Bucket: this.configService.get('AWS_S3_BUCKET'),
      Key: key,
    };

    await this.s3.deleteObject(deleteParams).promise();
  }
}

3. Frontend Upload Component

// apps/web/src/components/ui/file-upload.tsx
'use client';

import { useState, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Upload, X } from 'lucide-react';
import { cn } from '@/lib/utils';

interface FileUploadProps {
  accept?: string;
  maxSize?: number; // in MB
  onUpload: (file: File) => Promise<{ url: string }>;
  className?: string;
}

export function FileUpload({ 
  accept = 'image/*', 
  maxSize = 5,
  onUpload,
  className 
}: FileUploadProps) {
  const [isUploading, setIsUploading] = useState(false);
  const [progress, setProgress] = useState(0);
  const [previewUrl, setPreviewUrl] = useState<string | null>(null);
  const fileInputRef = useRef<HTMLInputElement>(null);

  const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    if (!file) return;

    // Validate file size
    if (file.size > maxSize * 1024 * 1024) {
      alert(`File size must be less than ${maxSize}MB`);
      return;
    }

    // Show preview for images
    if (file.type.startsWith('image/')) {
      const reader = new FileReader();
      reader.onload = (e) => setPreviewUrl(e.target?.result as string);
      reader.readAsDataURL(file);
    }

    try {
      setIsUploading(true);
      setProgress(0);

      // Simulate progress (in real app, use upload progress)
      const interval = setInterval(() => {
        setProgress((prev) => Math.min(prev + 10, 90));
      }, 100);

      const result = await onUpload(file);
      
      clearInterval(interval);
      setProgress(100);
      
      setTimeout(() => {
        setIsUploading(false);
        setProgress(0);
      }, 1000);

    } catch (error) {
      setIsUploading(false);
      setProgress(0);
      alert('Upload failed. Please try again.');
    }
  };

  const clearPreview = () => {
    setPreviewUrl(null);
    if (fileInputRef.current) {
      fileInputRef.current.value = '';
    }
  };

  return (
    <div className={cn('space-y-4', className)}>
      <input
        ref={fileInputRef}
        type="file"
        accept={accept}
        onChange={handleFileSelect}
        className="hidden"
      />
      
      {previewUrl ? (
        <div className="relative inline-block">
          <img
            src={previewUrl}
            alt="Preview"
            className="w-32 h-32 object-cover rounded-lg border"
          />
          <Button
            variant="destructive"
            size="sm"
            className="absolute -top-2 -right-2 h-6 w-6 rounded-full p-0"
            onClick={clearPreview}
          >
            <X className="h-3 w-3" />
          </Button>
        </div>
      ) : (
        <Button
          variant="outline"
          onClick={() => fileInputRef.current?.click()}
          disabled={isUploading}
          className="h-32 w-32 flex-col space-y-2"
        >
          <Upload className="h-8 w-8" />
          <span className="text-sm">Upload File</span>
        </Button>
      )}

      {isUploading && (
        <div className="space-y-2">
          <Progress value={progress} className="w-full" />
          <p className="text-sm text-muted-foreground">Uploading... {progress}%</p>
        </div>
      )}
    </div>
  );
}

Best Practices

Code Organization

  1. Feature-based Structure: Organize code by business features, not technical layers
  2. Shared Types: Keep API types in packages/shared for consistency
  3. Custom Hooks: Extract complex logic into reusable hooks
  4. Component Composition: Build complex UIs from simple, reusable components

Database Design

  1. Tenant Isolation: Always include organization context in multi-tenant tables
  2. Migrations: Test migrations on a copy of production data
  3. Indexes: Add indexes for commonly queried fields
  4. Constraints: Use database constraints to enforce data integrity

API Design

  1. RESTful Conventions: Follow REST conventions for predictable APIs
  2. Error Handling: Return consistent error responses
  3. Validation: Validate all inputs server-side
  4. Documentation: Keep Swagger documentation up-to-date

Security

  1. Input Validation: Never trust user input
  2. Authorization: Check permissions at the API level
  3. Rate Limiting: Protect against abuse
  4. Secrets Management: Use environment variables for sensitive data

ShopySeed provides a solid foundation that you can build upon. Focus on your unique business logic while leveraging the robust infrastructure already in place. 🚀

On this page