Hei teman-teman! Kali ini kita bakal ngomongin sesuatu yang cukup menarik nih - gimana caranya membangun sistem SaaS (Software as a Service) untuk manajemen laundry menggunakan teknologi terdepan seperti Next.js 15 dan Supabase. Mungkin kalian bertanya-tanya, "Kok laundry sih? Emangnya bisnis tradisional kayak gitu butuh teknologi canggih?" Nah, justru di situlah letak peluang emasnya!
Mengapa SaaS Laundry Management adalah Masa Depan Bisnis Laundry?
Bisnis laundry di Indonesia tuh sebenernya pasar yang sangat besar dan terus berkembang. Bayangin aja, dengan lifestyle masyarakat yang semakin sibuk, kebutuhan akan jasa laundry makin meningkat. Tapi masalahnya, kebanyakan bisnis laundry masih dikelola secara manual atau dengan sistem yang outdated. Di sinilah kesempatan kita untuk memberikan solusi yang game-changing.
Model SaaS sangat cocok untuk industri laundry karena beberapa alasan krusial. Pertama, pemilik bisnis laundry bisa mengelola multiple cabang dari satu dashboard terpusat. Bayangin kalau kamu punya 5 cabang laundry di berbagai lokasi, dengan sistem SaaS kamu bisa monitor semuanya real-time tanpa harus bolak-balik ke setiap cabang. Tracking pesanan jadi lebih mudah, customer bisa cek status cucian mereka kapan aja, dan yang paling penting - automated billing yang mengurangi human error.
Yang bikin menarik lagi, dengan analytics mendalam yang disediakan sistem SaaS, pemilik bisnis bisa dapetin insights berharga seperti peak hours, jenis layanan yang paling laku, customer behavior patterns, bahkan prediksi revenue bulanan. Semua ini tanpa perlu investasi infrastruktur IT yang gede-gedean.
Next.js 15: Fondasi Aplikasi Web Modern yang Powerful
Sekarang kita masuk ke bagian teknis yang seru nih. Kenapa kita pilih Next.js 15 sebagai frontend framework? Jawabannya simpel - karena Next.js 15 tuh beneran next-level dalam hal performa dan developer experience.
Fitur App Router yang ada di Next.js 15 memberikan kita fleksibilitas routing yang luar biasa. Kita bisa bikin nested layouts, loading states, error boundaries, dan parallel routes dengan sangat mudah. Ini sangat penting untuk aplikasi SaaS karena user experience harus seamless. Bayangin kalau customer lagi cek status pesanan tapi loading-nya lama atau error handling-nya jelek, pasti langsung cabut kan?
Yang paling keren adalah React Server Components (RSC) yang udah fully integrated di Next.js 15. Dengan RSC, kita bisa render komponen di server side, yang artinya bundle size di client jadi lebih kecil dan initial load time lebih cepat. Untuk aplikasi laundry management yang mungkin diakses dari berbagai device dengan koneksi internet yang bervariasi, ini sangat crucial.
Supabase: Backend-as-a-Service yang Revolusioner
Nah, untuk backend kita pakai Supabase yang beneran game-changer dalam dunia BaaS (Backend-as-a-Service). Kenapa Supabase? Karena dia tuh kayak Swiss Army knife-nya backend development - semua yang kita butuhin ada dalam satu platform.
Pertama, database PostgreSQL yang powerful dan scalable. PostgreSQL tuh bukan database sembarangan ya, ini enterprise-grade database yang dipake sama perusahaan-perusahaan besar. Dengan Supabase, kita dapetin full power PostgreSQL tanpa harus setup dan maintain server sendiri. Buat aplikasi laundry management, kita butuh complex queries untuk tracking orders, inventory management, customer data, dan financial reports - semua ini PostgreSQL handle dengan sangat baik.
Authentication system di Supabase juga top-notch. Kita bisa implement berbagai metode login seperti email/password, social logins (Google, Facebook), bahkan magic links. Security-wise, Supabase udah implement industry standards kayak JWT tokens dan row-level security, jadi data customer terjamin aman.
Yang paling exciting adalah real-time subscriptions. Dalam konteks laundry management, ini berarti customer bisa dapetin update status pesanan secara real-time tanpa harus refresh halaman. Staff laundry juga bisa dapetin notifikasi langsung kalau ada pesanan baru atau update dari cabang lain. Ini level user experience yang biasanya cuma ada di aplikasi-aplikasi premium.
Edge Functions di Supabase memungkinkan kita run serverless functions yang bisa handle complex business logic, integrasi dengan third-party services, atau automated tasks. Misalnya, kita bisa bikin function yang otomatis kirim WhatsApp notification ke customer kalau pesanan mereka udah selesai.
Kenapa Kombinasi Next.js 15 + Supabase Sangat Powerful?
Kombinasi Next.js 15 dan Supabase tuh kayak Batman dan Robin-nya web development. Next.js handle frontend dengan performa yang luar biasa, sementara Supabase provide backend infrastructure yang robust dan scalable.
Developer experience-nya juga incredible. Dengan Supabase CLI dan Next.js development server, kita bisa develop dan test aplikasi secara lokal dengan mudah. Auto-generated TypeScript types dari Supabase schema bikin development process jadi type-safe dan mengurangi runtime errors.
Scalability juga jadi nggak perlu dipikirin lagi. Next.js bisa di-deploy ke Vercel dengan zero configuration, sementara Supabase handle database scaling otomatis. Jadi kalau bisnis laundry kita berkembang dari 1 cabang jadi 100 cabang, sistem kita tetap bisa handle traffic dan data volume yang meningkat.
Setup Project Next.js 15 dan Konfigurasi Supabase untuk SaaS Laundry
Oke teman-teman, sekarang kita masuk ke bagian yang lebih hands-on nih! Di chapter ini kita bakal setup project Next.js 15 dari nol dan mengkonfigurasi Supabase sebagai backend kita. Ini foundational step yang sangat penting, jadi pastikan kalian ikutin setiap stepnya dengan teliti ya!
Membuat Project Next.js 15 dengan App Router dan TypeScript
Langkah pertama yang harus kita lakukan adalah membuat project Next.js 15 yang fresh. Kita bakal menggunakan template terbaru yang udah include semua tools essential yang kita butuhin.
Buka terminal kalian dan jalankan command berikut:
npx create-next-app@latest saas-laundry --typescript --tailwind --eslint --app
Command ini bakal create project baru dengan nama saas-laundry dan automatically setup beberapa tools penting:
- TypeScript untuk type safety yang sangat crucial dalam development aplikasi kompleks
- Tailwind CSS untuk styling yang efficient dan consistent
- ESLint untuk code quality dan consistency
- App Router yang merupakan fitur terbaru di Next.js 15
Setelah installation selesai, masuk ke direktori project:
cd saas-laundry
Sekarang coba jalankan development server untuk memastikan everything works properly:
npm run dev
Buka browser dan akses http://localhost:3000. Kalau kalian lihat halaman welcome Next.js, berarti setup awal udah berhasil!
Konfigurasi Environment Variables untuk Supabase
Environment variables itu sangat penting untuk menjaga security dan flexibility aplikasi kita. Untuk integrasi dengan Supabase, kita butuh beberapa variabel khusus yang akan kita setup di file .env.local.
Buat file .env.local di root directory project kalian:
touch .env.local
Kemudian isi file tersebut dengan variabel-variabel berikut:
# Supabase Configuration
NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key
# Application Configuration
NEXT_PUBLIC_SITE_URL=http://localhost:3000
# Stripe Configuration (akan kita gunakan nanti)
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
Penjelasan masing-masing variabel:
NEXT_PUBLIC_SUPABASE_URL: URL project Supabase kalian. Variabel ini public dan bisa diakses di client-side karena prefix NEXT_PUBLIC_.
NEXT_PUBLIC_SUPABASE_ANON_KEY: Anonymous key untuk akses public ke Supabase. Key ini safe untuk exposed di client-side karena dibatasi oleh Row Level Security.
SUPABASE_SERVICE_ROLE_KEY: Service role key yang punya full access ke database. Key ini hanya boleh digunakan di server-side dan never exposed ke client.
NEXT_PUBLIC_SITE_URL: Base URL aplikasi kita. Ini berguna untuk redirect URLs dan canonical URLs.
Struktur Folder yang Optimal dan Maintainable
Organisasi kode yang baik itu sangat crucial untuk long-term maintainability, apalagi untuk aplikasi SaaS yang bakal terus berkembang. Mari kita buat struktur folder yang optimal:
mkdir -p app/\\(dashboard\\)
mkdir -p app/\\(auth\\)
mkdir -p components/ui
mkdir -p lib/supabase
mkdir -p types
mkdir -p hooks
mkdir -p utils
mkdir -p constants
Penjelasan struktur folder:
app/(dashboard): Route group untuk halaman-halaman dashboard yang membutuhkan authentication. Kurung dalam nama folder menandakan route group di Next.js 15, yang tidak akan mempengaruhi URL structure.
app/(auth): Route group untuk halaman authentication seperti login, register, dan forgot password.
components/ui: Komponen UI reusable seperti buttons, modals, form inputs, dan lain-lain.
lib/supabase: Konfigurasi dan utility functions untuk Supabase client.
types: TypeScript type definitions untuk aplikasi kita.
hooks: Custom React hooks untuk logic yang reusable.
utils: Utility functions yang bisa dipakai di berbagai tempat.
constants: Konstanta-konstanta aplikasi seperti enum values, configuration objects, dll.
Buat juga beberapa file penting dalam struktur ini:
touch lib/supabase/client.ts
touch lib/supabase/server.ts
touch lib/supabase/middleware.ts
touch types/database.ts
touch types/index.ts
touch utils/constants.ts
Konfigurasi next.config.js untuk Optimasi Performa
File next.config.js adalah tempat kita melakukan various optimizations dan configurations. Mari kita update file ini untuk mendukung aplikasi SaaS kita:
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
// Enable Server Components (default di Next.js 15)
serverComponentsExternalPackages: ['@supabase/supabase-js'],
},
// Image optimization configuration
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.supabase.co', // Untuk Supabase Storage images
port: '',
pathname: '/storage/v1/object/public/**',
},
],
},
// Environment variables validation
env: {
CUSTOM_KEY: process.env.CUSTOM_KEY,
},
// Redirect configuration
async redirects() {
return [
{
source: '/dashboard',
destination: '/dashboard/overview',
permanent: true,
},
];
},
// Headers configuration untuk security
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'Referrer-Policy',
value: 'origin-when-cross-origin',
},
],
},
];
},
}
module.exports = nextConfig
Setup Middleware untuk Route Protection
Middleware di Next.js 15 sangat powerful untuk handling authentication dan route protection. Mari kita buat middleware yang akan protect route-route yang membutuhkan authentication.
Buat file middleware.ts di root directory:
import { createMiddlewareClient } from '@supabase/auth-helpers-nextjs'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export async function middleware(req: NextRequest) {
const res = NextResponse.next()
// Create a Supabase client configured to use cookies
const supabase = createMiddlewareClient({ req, res })
// Refresh session if expired - required for Server Components
const { data: { session } } = await supabase.auth.getSession()
// Protected routes yang membutuhkan authentication
const protectedRoutes = ['/dashboard', '/settings', '/billing']
const authRoutes = ['/login', '/register', '/forgot-password']
const isProtectedRoute = protectedRoutes.some(route =>
req.nextUrl.pathname.startsWith(route)
)
const isAuthRoute = authRoutes.some(route =>
req.nextUrl.pathname.startsWith(route)
)
// Redirect ke login jika mengakses protected route tanpa session
if (isProtectedRoute && !session) {
const redirectUrl = new URL('/login', req.url)
redirectUrl.searchParams.set('redirectTo', req.nextUrl.pathname)
return NextResponse.redirect(redirectUrl)
}
// Redirect ke dashboard jika sudah login tapi mengakses auth routes
if (isAuthRoute && session) {
return NextResponse.redirect(new URL('/dashboard', req.url))
}
return res
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
'/((?!_next/static|_next/image|favicon.ico).*)',
],
}
Setup Supabase Project dan Konfigurasi Database
Sekarang kita beralih ke setup Supabase project. Langkah pertama adalah membuat project baru di Supabase Dashboard.
Kunjungi supabase.com dan login dengan akun kalian. Kalau belum punya akun, daftar dulu ya (gratis kok!).
Setelah login, klik tombol "New Project" dan isi detail berikut:
- Name: SaaS Laundry Management
- Database Password: Buat password yang strong (simpan baik-baik!)
- Region: Pilih yang terdekat dengan target user kalian
- Pricing Plan: Pilih Free tier untuk development
Tunggu beberapa menit sampai project setup selesai. Setelah itu, kalian bakal diarahkan ke project dashboard.
Copy Supabase Credentials ke Environment Variables
Di Supabase dashboard, masuk ke Settings > API. Di sana kalian bakal nemuin:
- Project URL: Copy ini ke
NEXT_PUBLIC_SUPABASE_URL - anon/public key: Copy ini ke
NEXT_PUBLIC_SUPABASE_ANON_KEY - service_role key: Copy ini ke
SUPABASE_SERVICE_ROLE_KEY
Update file .env.local kalian dengan values yang actual:
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here
NEXT_PUBLIC_SITE_URL=http://localhost:3000
Install Supabase Dependencies
Sekarang install dependencies yang kita butuhin untuk integrasi dengan Supabase:
npm install @supabase/supabase-js @supabase/auth-helpers-nextjs
Dependencies ini provide:
- @supabase/supabase-js: Core Supabase client library
- @supabase/auth-helpers-nextjs: Helper utilities khusus untuk Next.js integration
Setup Supabase Client untuk Client-side Operations
Buat file lib/supabase/client.ts:
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'
import { Database } from '@/types/database'
export const createClient = () =>
createClientComponentClient<Database>()
File ini handle Supabase client untuk operasi di client-side components (components yang render di browser).
Setup Supabase Client untuk Server-side Operations
Buat file lib/supabase/server.ts:
import { createServerComponentClient } from '@supabase/auth-helpers-nextjs'
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'
import { cookies } from 'next/headers'
import { Database } from '@/types/database'
// Untuk Server Components
export const createServerClient = () =>
createServerComponentClient<Database>({ cookies })
// Untuk Route Handlers (API Routes)
export const createRouteHandlerSupabaseClient = () =>
createRouteHandlerClient<Database>({ cookies })
File ini handle Supabase client untuk server-side operations, baik di Server Components maupun API Routes.
Konfigurasi Authentication Settings di Supabase Dashboard
Sekarang kita perlu konfigurasi authentication settings di Supabase dashboard. Masuk ke Authentication > Settings.
Enabled Providers:
- Email sudah enabled by default
- Untuk enable Google OAuth: masuk ke Providers tab, klik Google, dan isi Client ID dan Client Secret dari Google Console
- Untuk GitHub OAuth: sama seperti Google, tapi menggunakan GitHub OAuth App credentials
Site URL Configuration: Di bagian Site URL, isi dengan:
- Site URL:
http://localhost:3000(untuk development) - Redirect URLs:
http://localhost:3000/auth/callbackhttps://yourdomain.com/auth/callback(untuk production nanti)
Email Templates: Masuk ke Authentication > Email Templates untuk customize email templates:
Confirm Signup Template:
<h2>Confirm your signup</h2>
<p>Follow this link to confirm your account:</p>
<p><a href="{{ .ConfirmationURL }}">Confirm your account</a></p>
<p>Welcome to SaaS Laundry Management!</p>
Reset Password Template:
<h2>Reset Password</h2>
<p>Follow this link to reset the password for your account:</p>
<p><a href="{{ .ConfirmationURL }}">Reset Password</a></p>
<p>If you didn't request this, you can safely ignore this email.</p>
Testing Koneksi Supabase
Sekarang mari kita test apakah koneksi ke Supabase udah berjalan dengan baik. Buat file test sederhana di app/test-supabase/page.tsx:
import { createServerClient } from '@/lib/supabase/server'
export default async function TestSupabase() {
const supabase = createServerClient()
// Test koneksi dengan query sederhana
const { data, error } = await supabase
.from('auth.users')
.select('count')
.single()
return (
<div className="p-8">
<h1 className="text-2xl font-bold">Supabase Connection Test</h1>
{error ? (
<p className="text-red-500">Error: {error.message}</p>
) : (
<p className="text-green-500">✅ Supabase connection successful!</p>
)}
</div>
)
}
Restart development server kalian dan akses http://localhost:3000/test-supabase. Kalau kalian lihat pesan success, berarti koneksi Supabase udah berjalan dengan baik!
Database Schema Design untuk SaaS Laundry Management
Nah, sekarang kita masuk ke bagian yang sangat crucial nih - database schema design! Ini tuh foundation dari seluruh aplikasi kita, jadi harus bener-bener well-thought dan scalable. Kita bakal design schema yang comprehensive tapi tetap efficient dan maintainable untuk jangka panjang.
Konsep Multi-Tenant Architecture
Sebelum masuk ke detail tabel, kita harus pahami dulu konsep multi-tenant architecture yang akan kita implementasikan. Dalam SaaS Laundry Management, setiap company (perusahaan laundry) adalah tenant yang terpisah. Mereka bisa punya multiple cabang, employees, customers, dan orders - tapi semuanya harus terisolasi dari company lain.
Untuk mencapai ini, kita akan menggunakan Row Level Security (RLS) di PostgreSQL yang sangat powerful. Setiap query akan automatically filtered berdasarkan company_id user yang sedang login, jadi data antar company benar-benar terpisah dan secure.
Tabel Profiles - User Profile Management
Mari kita mulai dengan tabel yang paling fundamental - profiles. Tabel ini akan menyimpan additional information untuk setiap user yang register melalui Supabase Auth.
-- Tabel profiles untuk menyimpan data user profile
CREATE TABLE profiles (
id UUID REFERENCES auth.users(id) ON DELETE CASCADE PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
full_name TEXT NOT NULL,
avatar_url TEXT,
phone TEXT,
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'owner', 'manager', 'employee', 'user')),
company_id UUID REFERENCES companies(id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes untuk performa query
CREATE INDEX idx_profiles_company_id ON profiles(company_id);
CREATE INDEX idx_profiles_role ON profiles(role);
CREATE INDEX idx_profiles_email ON profiles(email);
-- Trigger untuk auto-update updated_at
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_profiles_updated_at BEFORE UPDATE ON profiles
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
Penjelasan struktur profiles:
- id: Primary key yang reference ke auth.users untuk maintain consistency dengan Supabase Auth
- role: Enum untuk different access levels dalam sistem
- company_id: Foreign key ke companies table untuk multi-tenant isolation
Tabel Companies - Informasi Perusahaan Laundry
Companies table adalah core dari multi-tenant architecture kita. Setiap company represent satu business entity yang bisa punya multiple laundries (cabang).
-- Tabel companies untuk informasi perusahaan laundry
CREATE TABLE companies (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
logo_url TEXT,
website TEXT,
email TEXT,
phone TEXT,
address TEXT,
city TEXT,
province TEXT,
postal_code TEXT,
country TEXT DEFAULT 'Indonesia',
tax_number TEXT,
business_license TEXT,
is_active BOOLEAN DEFAULT true,
settings JSONB DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes untuk performa
CREATE INDEX idx_companies_is_active ON companies(is_active);
CREATE INDEX idx_companies_city ON companies(city);
CREATE INDEX idx_companies_name ON companies(name);
-- Trigger untuk auto-update updated_at
CREATE TRIGGER update_companies_updated_at BEFORE UPDATE ON companies
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
Field settings menggunakan JSONB untuk menyimpan company-specific configurations seperti pricing rules, notification preferences, atau custom business logic.
Tabel Laundries - Data Cabang
Laundries table menyimpan informasi setiap cabang dari company. Satu company bisa punya banyak cabang di lokasi berbeda.
-- Tabel laundries untuk data cabang
CREATE TABLE laundries (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
name TEXT NOT NULL,
code TEXT NOT NULL, -- Kode unik untuk cabang (misal: JKT001, BDG002)
address TEXT NOT NULL,
city TEXT NOT NULL,
province TEXT NOT NULL,
postal_code TEXT,
phone TEXT,
email TEXT,
manager_id UUID REFERENCES profiles(id) ON DELETE SET NULL,
operating_hours JSONB DEFAULT '{}', -- Jam operasional per hari
capacity INTEGER DEFAULT 100, -- Kapasitas order per hari
is_active BOOLEAN DEFAULT true,
lat DECIMAL(10, 8), -- Latitude untuk maps integration
lng DECIMAL(11, 8), -- Longitude untuk maps integration
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Constraint untuk unique code per company
UNIQUE(company_id, code)
);
-- Indexes untuk performa query
CREATE INDEX idx_laundries_company_id ON laundries(company_id);
CREATE INDEX idx_laundries_is_active ON laundries(is_active);
CREATE INDEX idx_laundries_city ON laundries(city);
CREATE INDEX idx_laundries_manager_id ON laundries(manager_id);
-- Trigger untuk auto-update updated_at
CREATE TRIGGER update_laundries_updated_at BEFORE UPDATE ON laundries
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
Operating_hours field menggunakan JSONB untuk flexibility dalam menyimpan jam operasional yang bisa berbeda setiap hari.
Tabel Employees - Manajemen Karyawan
Employees table untuk manage karyawan di setiap cabang dengan relasi ke profiles dan laundries.
-- Tabel employees untuk manajemen karyawan
CREATE TABLE employees (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
laundry_id UUID NOT NULL REFERENCES laundries(id) ON DELETE CASCADE,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
employee_code TEXT NOT NULL,
position TEXT NOT NULL,
salary DECIMAL(15, 2),
commission_rate DECIMAL(5, 2) DEFAULT 0.00, -- Persentase komisi
hire_date DATE NOT NULL,
termination_date DATE,
is_active BOOLEAN DEFAULT true,
permissions JSONB DEFAULT '{}', -- Specific permissions untuk employee
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Constraint untuk unique employee_code per company
UNIQUE(company_id, employee_code)
);
-- Indexes untuk performa
CREATE INDEX idx_employees_profile_id ON employees(profile_id);
CREATE INDEX idx_employees_laundry_id ON employees(laundry_id);
CREATE INDEX idx_employees_company_id ON employees(company_id);
CREATE INDEX idx_employees_is_active ON employees(is_active);
-- Trigger untuk auto-update updated_at
CREATE TRIGGER update_employees_updated_at BEFORE UPDATE ON employees
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
Tabel Customers - Database Pelanggan
Customers table untuk menyimpan data pelanggan yang bisa digunakan across multiple laundries dalam satu company.
-- Tabel customers untuk database pelanggan
CREATE TABLE customers (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
customer_code TEXT NOT NULL,
full_name TEXT NOT NULL,
email TEXT,
phone TEXT NOT NULL,
address TEXT,
city TEXT,
postal_code TEXT,
date_of_birth DATE,
gender TEXT CHECK (gender IN ('male', 'female', 'other')),
membership_type TEXT DEFAULT 'regular' CHECK (membership_type IN ('regular', 'silver', 'gold', 'platinum')),
total_orders INTEGER DEFAULT 0,
total_spent DECIMAL(15, 2) DEFAULT 0.00,
loyalty_points INTEGER DEFAULT 0,
notes TEXT,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Constraint untuk unique customer_code per company
UNIQUE(company_id, customer_code)
);
-- Indexes untuk performa query
CREATE INDEX idx_customers_company_id ON customers(company_id);
CREATE INDEX idx_customers_phone ON customers(phone);
CREATE INDEX idx_customers_email ON customers(email);
CREATE INDEX idx_customers_membership_type ON customers(membership_type);
CREATE INDEX idx_customers_is_active ON customers(is_active);
-- Trigger untuk auto-update updated_at
CREATE TRIGGER update_customers_updated_at BEFORE UPDATE ON customers
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
Tabel Service Types - Jenis Layanan Laundry
Service types table untuk define berbagai jenis layanan yang ditawarkan seperti cuci kering, setrika, dry cleaning, dll.
-- Tabel service_types untuk jenis layanan laundry
CREATE TABLE service_types (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
price_per_kg DECIMAL(10, 2),
price_per_item DECIMAL(10, 2),
pricing_type TEXT NOT NULL DEFAULT 'per_kg' CHECK (pricing_type IN ('per_kg', 'per_item', 'flat_rate')),
estimated_duration INTEGER NOT NULL, -- Durasi estimasi dalam jam
is_active BOOLEAN DEFAULT true,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes untuk performa
CREATE INDEX idx_service_types_company_id ON service_types(company_id);
CREATE INDEX idx_service_types_is_active ON service_types(is_active);
CREATE INDEX idx_service_types_sort_order ON service_types(sort_order);
-- Trigger untuk auto-update updated_at
CREATE TRIGGER update_service_types_updated_at BEFORE UPDATE ON service_types
FOR EACH ROW EXECUTE FUNCTION update_service_types_column();
Tabel Orders - Pesanan dengan Status Tracking
Orders table adalah core business logic dari aplikasi kita. Di sini semua transaksi laundry akan tercatat dengan status tracking yang comprehensive.
-- Tabel orders untuk pesanan dengan status tracking
CREATE TABLE orders (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
laundry_id UUID NOT NULL REFERENCES laundries(id) ON DELETE CASCADE,
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
employee_id UUID REFERENCES employees(id) ON DELETE SET NULL,
order_number TEXT NOT NULL,
-- Order details
items JSONB NOT NULL, -- Array of items dengan detail masing-masing
total_weight DECIMAL(8, 2),
total_items INTEGER,
subtotal DECIMAL(12, 2) NOT NULL,
discount_amount DECIMAL(12, 2) DEFAULT 0.00,
tax_amount DECIMAL(12, 2) DEFAULT 0.00,
total_amount DECIMAL(12, 2) NOT NULL,
-- Status tracking
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN (
'pending', 'confirmed', 'in_progress', 'washing', 'drying',
'ironing', 'quality_check', 'ready', 'completed', 'cancelled'
)),
-- Timestamps untuk tracking
order_date TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
pickup_date TIMESTAMP WITH TIME ZONE,
estimated_completion TIMESTAMP WITH TIME ZONE,
actual_completion TIMESTAMP WITH TIME ZONE,
delivery_date TIMESTAMP WITH TIME ZONE,
-- Payment info
payment_status TEXT DEFAULT 'pending' CHECK (payment_status IN ('pending', 'partial', 'paid', 'refunded')),
payment_method TEXT CHECK (payment_method IN ('cash', 'card', 'transfer', 'ewallet', 'credit')),
paid_amount DECIMAL(12, 2) DEFAULT 0.00,
-- Additional info
notes TEXT,
special_instructions TEXT,
priority_level TEXT DEFAULT 'normal' CHECK (priority_level IN ('low', 'normal', 'high', 'urgent')),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Constraint untuk unique order_number per company
UNIQUE(company_id, order_number)
);
-- Indexes untuk performa query yang optimal
CREATE INDEX idx_orders_company_id ON orders(company_id);
CREATE INDEX idx_orders_laundry_id ON orders(laundry_id);
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_order_date ON orders(order_date);
CREATE INDEX idx_orders_payment_status ON orders(payment_status);
CREATE INDEX idx_orders_order_number ON orders(order_number);
-- Composite index untuk queries yang sering digunakan
CREATE INDEX idx_orders_company_status_date ON orders(company_id, status, order_date);
CREATE INDEX idx_orders_laundry_status ON orders(laundry_id, status);
-- Trigger untuk auto-update updated_at
CREATE TRIGGER update_orders_updated_at BEFORE UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
Tabel Subscription Plans - Paket SaaS
Subscription plans table untuk define berbagai tier SaaS yang ditawarkan kepada companies.
-- Tabel subscription_plans untuk paket SaaS
CREATE TABLE subscription_plans (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT,
price_monthly DECIMAL(10, 2) NOT NULL,
price_yearly DECIMAL(10, 2),
features JSONB NOT NULL, -- Daftar features yang included
limits JSONB NOT NULL, -- Limits seperti max_laundries, max_employees, dll
is_active BOOLEAN DEFAULT true,
sort_order INTEGER DEFAULT 0,
stripe_price_id TEXT, -- Stripe Price ID untuk payment integration
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_subscription_plans_is_active ON subscription_plans(is_active);
CREATE INDEX idx_subscription_plans_sort_order ON subscription_plans(sort_order);
-- Insert default plans
INSERT INTO subscription_plans (name, description, price_monthly, price_yearly, features, limits) VALUES
('Starter', 'Perfect for small laundry business', 99000, 990000,
'["Order Management", "Customer Database", "Basic Reports"]',
'{"max_laundries": 1, "max_employees": 5, "max_orders_per_month": 500}'),
('Professional', 'Great for growing business', 199000, 1990000,
'["All Starter Features", "Multi-branch Management", "Advanced Analytics", "WhatsApp Integration"]',
'{"max_laundries": 5, "max_employees": 25, "max_orders_per_month": 2000}'),
('Enterprise', 'For large laundry chains', 399000, 3990000,
'["All Professional Features", "Custom Branding", "API Access", "Priority Support"]',
'{"max_laundries": -1, "max_employees": -1, "max_orders_per_month": -1}');
-- Trigger untuk auto-update updated_at
CREATE TRIGGER update_subscription_plans_updated_at BEFORE UPDATE ON subscription_plans
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
Tabel Subscriptions - Status Berlangganan User
Subscriptions table untuk track subscription status setiap company.
-- Tabel subscriptions untuk status berlangganan user
CREATE TABLE subscriptions (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
plan_id UUID NOT NULL REFERENCES subscription_plans(id) ON DELETE RESTRICT,
stripe_subscription_id TEXT UNIQUE, -- Stripe Subscription ID
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN (
'trialing', 'active', 'past_due', 'canceled', 'unpaid', 'incomplete'
)),
current_period_start TIMESTAMP WITH TIME ZONE NOT NULL,
current_period_end TIMESTAMP WITH TIME ZONE NOT NULL,
trial_start TIMESTAMP WITH TIME ZONE,
trial_end TIMESTAMP WITH TIME ZONE,
canceled_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes untuk performa
CREATE INDEX idx_subscriptions_company_id ON subscriptions(company_id);
CREATE INDEX idx_subscriptions_status ON subscriptions(status);
CREATE INDEX idx_subscriptions_current_period_end ON subscriptions(current_period_end);
-- Trigger untuk auto-update updated_at
CREATE TRIGGER update_subscriptions_updated_at BEFORE UPDATE ON subscriptions
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
Tabel Transactions - Riwayat Pembayaran
Transactions table untuk menyimpan semua riwayat pembayaran, baik untuk orders maupun subscriptions.
-- Tabel transactions untuk riwayat pembayaran
CREATE TABLE transactions (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
order_id UUID REFERENCES orders(id) ON DELETE SET NULL,
subscription_id UUID REFERENCES subscriptions(id) ON DELETE SET NULL,
stripe_payment_intent_id TEXT,
-- Transaction details
amount DECIMAL(12, 2) NOT NULL,
currency TEXT DEFAULT 'IDR',
transaction_type TEXT NOT NULL CHECK (transaction_type IN ('order_payment', 'subscription_payment', 'refund')),
payment_method TEXT CHECK (payment_method IN ('cash', 'card', 'transfer', 'ewallet')),
-- Status
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'succeeded', 'failed', 'canceled', 'refunded')),
-- Metadata
metadata JSONB DEFAULT '{}',
notes TEXT,
processed_by UUID REFERENCES profiles(id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Constraint: either order_id or subscription_id must be present
CHECK ((order_id IS NOT NULL) OR (subscription_id IS NOT NULL))
);
-- Indexes untuk performa
CREATE INDEX idx_transactions_company_id ON transactions(company_id);
CREATE INDEX idx_transactions_order_id ON transactions(order_id);
CREATE INDEX idx_transactions_subscription_id ON transactions(subscription_id);
CREATE INDEX idx_transactions_status ON transactions(status);
CREATE INDEX idx_transactions_transaction_type ON transactions(transaction_type);
CREATE INDEX idx_transactions_created_at ON transactions(created_at);
-- Trigger untuk auto-update updated_at
CREATE TRIGGER update_transactions_updated_at BEFORE UPDATE ON transactions
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
Row Level Security (RLS) Implementation
Sekarang bagian yang paling critical - implementing Row Level Security untuk data isolation antar tenant. RLS memastikan setiap company hanya bisa akses data mereka sendiri.
-- Enable RLS untuk semua tables
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE companies ENABLE ROW LEVEL SECURITY;
ALTER TABLE laundries ENABLE ROW LEVEL SECURITY;
ALTER TABLE employees ENABLE ROW LEVEL SECURITY;
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
ALTER TABLE service_types ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;
ALTER TABLE transactions ENABLE ROW LEVEL SECURITY;
-- RLS Policies untuk profiles
CREATE POLICY "Users can view own profile" ON profiles FOR SELECT USING (auth.uid() = id);
CREATE POLICY "Users can update own profile" ON profiles FOR UPDATE USING (auth.uid() = id);
-- RLS Policies untuk companies
CREATE POLICY "Company members can view company" ON companies FOR SELECT
USING (id IN (SELECT company_id FROM profiles WHERE id = auth.uid()));
CREATE POLICY "Company owners can update company" ON companies FOR UPDATE
USING (id IN (
SELECT company_id FROM profiles
WHERE id = auth.uid() AND role IN ('owner', 'admin')
));
-- RLS Policies untuk laundries
CREATE POLICY "Company members can view laundries" ON laundries FOR SELECT
USING (company_id IN (SELECT company_id FROM profiles WHERE id = auth.uid()));
CREATE POLICY "Managers can manage laundries" ON laundries FOR ALL
USING (company_id IN (
SELECT company_id FROM profiles
WHERE id = auth.uid() AND role IN ('owner', 'admin', 'manager')
));
-- RLS Policies untuk employees
CREATE POLICY "Company members can view employees" ON employees FOR SELECT
USING (company_id IN (SELECT company_id FROM profiles WHERE id = auth.uid()));
CREATE POLICY "Managers can manage employees" ON employees FOR ALL
USING (company_id IN (
SELECT company_id FROM profiles
WHERE id = auth.uid() AND role IN ('owner', 'admin', 'manager')
));
-- RLS Policies untuk customers
CREATE POLICY "Company members can view customers" ON customers FOR SELECT
USING (company_id IN (SELECT company_id FROM profiles WHERE id = auth.uid()));
CREATE POLICY "Employees can manage customers" ON customers FOR ALL
USING (company_id IN (
SELECT company_id FROM profiles
WHERE id = auth.uid() AND role IN ('owner', 'admin', 'manager', 'employee')
));
-- RLS Policies untuk service_types
CREATE POLICY "Company members can view service types" ON service_types FOR SELECT
USING (company_id IN (SELECT company_id FROM profiles WHERE id = auth.uid()));
CREATE POLICY "Managers can manage service types" ON service_types FOR ALL
USING (company_id IN (
SELECT company_id FROM profiles
WHERE id = auth.uid() AND role IN ('owner', 'admin', 'manager')
));
-- RLS Policies untuk orders
CREATE POLICY "Company members can view orders" ON orders FOR SELECT
USING (company_id IN (SELECT company_id FROM profiles WHERE id = auth.uid()));
CREATE POLICY "Employees can manage orders" ON orders FOR ALL
USING (company_id IN (
SELECT company_id FROM profiles
WHERE id = auth.uid() AND role IN ('owner', 'admin', 'manager', 'employee')
));
-- RLS Policies untuk subscriptions
CREATE POLICY "Company members can view subscription" ON subscriptions FOR SELECT
USING (company_id IN (SELECT company_id FROM profiles WHERE id = auth.uid()));
CREATE POLICY "Owners can manage subscription" ON subscriptions FOR ALL
USING (company_id IN (
SELECT company_id FROM profiles
WHERE id = auth.uid() AND role IN ('owner', 'admin')
));
-- RLS Policies untuk transactions
CREATE POLICY "Company members can view transactions" ON transactions FOR SELECT
USING (company_id IN (SELECT company_id FROM profiles WHERE id = auth.uid()));
CREATE POLICY "Managers can create transactions" ON transactions FOR INSERT
USING (company_id IN (
SELECT company_id FROM profiles
WHERE id = auth.uid() AND role IN ('owner', 'admin', 'manager', 'employee')
));
Database Functions untuk Business Logic
Mari kita buat beberapa database functions untuk handle common business logic:
-- Function untuk generate order number
CREATE OR REPLACE FUNCTION generate_order_number(company_uuid UUID, laundry_uuid UUID)
RETURNS TEXT AS $$
DECLARE
laundry_code TEXT;
sequence_num INTEGER;
order_num TEXT;
BEGIN
-- Get laundry code
SELECT code INTO laundry_code FROM laundries WHERE id = laundry_uuid;
-- Get next sequence number untuk hari ini
SELECT COALESCE(MAX(CAST(RIGHT(order_number, 4) AS INTEGER)), 0) + 1
INTO sequence_num
FROM orders
WHERE company_id = company_uuid
AND DATE(created_at) = CURRENT_DATE;
-- Generate order number: LAUNDRYCODE-YYYYMMDD-NNNN
order_num := laundry_code || '-' || TO_CHAR(CURRENT_DATE, 'YYYYMMDD') || '-' || LPAD(sequence_num::TEXT, 4, '0');
RETURN order_num;
END;
$$ LANGUAGE plpgsql;
-- Function untuk update customer statistics
CREATE OR REPLACE FUNCTION update_customer_stats()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
UPDATE customers SET
total_orders = (
SELECT COUNT(*) FROM orders
WHERE customer_id = NEW.customer_id AND status = 'completed'
),
total_spent = (
SELECT COALESCE(SUM(total_amount), 0) FROM orders
WHERE customer_id = NEW.customer_id AND status = 'completed'
)
WHERE id = NEW.customer_id;
RETURN NEW;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
-- Trigger untuk auto-update customer statistics
CREATE TRIGGER update_customer_stats_trigger
AFTER INSERT OR UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION update_customer_stats();
Implementasi Authentication System dengan Supabase Auth
Alright teman-teman! Sekarang kita masuk ke salah satu bagian yang paling crucial dalam aplikasi SaaS - authentication system. Kita bakal build authentication yang robust dengan multiple user roles, custom hooks untuk state management, dan route protection yang dynamic. Let's dive in!
Setup Dependencies untuk Authentication
Pertama-tama, kita perlu install beberapa dependencies yang akan kita gunakan untuk authentication dan form validation:
npm install react-hook-form @hookform/resolvers zod @radix-ui/react-dialog @radix-ui/react-label @radix-ui/react-slot lucide-react
Dependencies ini akan provide:
- react-hook-form: Untuk efficient form handling
- zod: Untuk schema validation yang type-safe
- @radix-ui components: Untuk accessible UI components
- lucide-react: Untuk icons
Custom Auth Hook - useAuth.ts
Mari kita buat custom hook yang akan handle semua authentication logic. Hook ini akan jadi central point untuk managing user state across aplikasi.
// hooks/useAuth.ts
'use client'
import { createContext, useContext, useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
import type { User, Session } from '@supabase/supabase-js'
export interface UserProfile {
id: string
email: string
full_name: string
avatar_url?: string
phone?: string
role: 'admin' | 'owner' | 'manager' | 'employee' | 'user'
company_id?: string
company?: {
id: string
name: string
is_active: boolean
}
}
interface AuthContextType {
user: User | null
profile: UserProfile | null
session: Session | null
loading: boolean
signIn: (email: string, password: string) => Promise<{ error?: string }>
signUp: (email: string, password: string, fullName: string) => Promise<{ error?: string }>
signOut: () => Promise<void>
resetPassword: (email: string) => Promise<{ error?: string }>
hasRole: (roles: string | string[]) => boolean
hasPermission: (permission: string) => boolean
refreshProfile: () => Promise<void>
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [profile, setProfile] = useState<UserProfile | null>(null)
const [session, setSession] = useState<Session | null>(null)
const [loading, setLoading] = useState(true)
const router = useRouter()
const supabase = createClient()
// Fetch user profile data
const fetchProfile = async (userId: string) => {
try {
const { data, error } = await supabase
.from('profiles')
.select(`
*,
company:companies(
id,
name,
is_active
)
`)
.eq('id', userId)
.single()
if (error) throw error
setProfile(data as UserProfile)
} catch (error) {
console.error('Error fetching profile:', error)
setProfile(null)
}
}
// Initialize auth state
useEffect(() => {
const initializeAuth = async () => {
try {
const { data: { session }, error } = await supabase.auth.getSession()
if (error) throw error
setSession(session)
setUser(session?.user ?? null)
if (session?.user) {
await fetchProfile(session.user.id)
}
} catch (error) {
console.error('Error initializing auth:', error)
} finally {
setLoading(false)
}
}
initializeAuth()
// Listen for auth changes
const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (event, session) => {
setSession(session)
setUser(session?.user ?? null)
if (session?.user) {
await fetchProfile(session.user.id)
} else {
setProfile(null)
}
setLoading(false)
}
)
return () => subscription.unsubscribe()
}, [])
// Sign in function
const signIn = async (email: string, password: string) => {
try {
const { error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) throw error
router.push('/dashboard')
return {}
} catch (error: any) {
return { error: error.message }
}
}
// Sign up function
const signUp = async (email: string, password: string, fullName: string) => {
try {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
data: {
full_name: fullName,
},
},
})
if (error) throw error
// Create profile record
if (data.user) {
const { error: profileError } = await supabase
.from('profiles')
.insert({
id: data.user.id,
email: data.user.email!,
full_name: fullName,
role: 'user',
})
if (profileError) throw profileError
}
return {}
} catch (error: any) {
return { error: error.message }
}
}
// Sign out function
const signOut = async () => {
await supabase.auth.signOut()
setUser(null)
setProfile(null)
setSession(null)
router.push('/login')
}
// Reset password function
const resetPassword = async (email: string) => {
try {
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/auth/reset-password`,
})
if (error) throw error
return {}
} catch (error: any) {
return { error: error.message }
}
}
// Check if user has specific role(s)
const hasRole = (roles: string | string[]) => {
if (!profile) return false
const roleArray = Array.isArray(roles) ? roles : [roles]
return roleArray.includes(profile.role)
}
// Check if user has specific permission
const hasPermission = (permission: string) => {
if (!profile) return false
// Define role-based permissions
const rolePermissions = {
admin: ['*'], // Admin has all permissions
owner: [
'company.manage',
'laundry.manage',
'employee.manage',
'customer.manage',
'order.manage',
'service.manage',
'billing.manage',
'analytics.view',
],
manager: [
'laundry.view',
'employee.manage',
'customer.manage',
'order.manage',
'service.manage',
'analytics.view',
],
employee: [
'customer.manage',
'order.manage',
'service.view',
],
user: ['profile.view'],
}
const userPermissions = rolePermissions[profile.role] || []
return userPermissions.includes('*') || userPermissions.includes(permission)
}
// Refresh profile data
const refreshProfile = async () => {
if (user) {
await fetchProfile(user.id)
}
}
const value = {
user,
profile,
session,
loading,
signIn,
signUp,
signOut,
resetPassword,
hasRole,
hasPermission,
refreshProfile,
}
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
Enhanced Middleware untuk Route Protection
Sekarang mari kita update middleware untuk handle route protection yang lebih sophisticated berdasarkan user roles:
// middleware.ts
import { createMiddlewareClient } from '@supabase/auth-helpers-nextjs'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
interface RouteConfig {
path: string
roles?: string[]
permissions?: string[]
requireCompany?: boolean
}
// Define protected routes dengan role requirements
const protectedRoutes: RouteConfig[] = [
{
path: '/dashboard',
roles: ['admin', 'owner', 'manager', 'employee'],
requireCompany: true,
},
{
path: '/companies',
roles: ['admin'],
},
{
path: '/billing',
roles: ['admin', 'owner'],
requireCompany: true,
},
{
path: '/analytics',
roles: ['admin', 'owner', 'manager'],
requireCompany: true,
},
{
path: '/settings',
roles: ['admin', 'owner', 'manager'],
requireCompany: true,
},
]
// Auth routes yang tidak boleh diakses jika sudah login
const authRoutes = ['/login', '/register', '/forgot-password']
export async function middleware(req: NextRequest) {
const res = NextResponse.next()
const supabase = createMiddlewareClient({ req, res })
try {
// Refresh session if expired
const { data: { session }, error } = await supabase.auth.getSession()
if (error) {
console.error('Auth error:', error)
return NextResponse.redirect(new URL('/login', req.url))
}
const currentPath = req.nextUrl.pathname
// Check if current path is an auth route
const isAuthRoute = authRoutes.some(route => currentPath.startsWith(route))
// Redirect authenticated users away from auth pages
if (isAuthRoute && session) {
return NextResponse.redirect(new URL('/dashboard', req.url))
}
// Check if current path is protected
const matchedRoute = protectedRoutes.find(route =>
currentPath.startsWith(route.path)
)
if (matchedRoute) {
// Require authentication
if (!session) {
const redirectUrl = new URL('/login', req.url)
redirectUrl.searchParams.set('redirectTo', currentPath)
return NextResponse.redirect(redirectUrl)
}
// Fetch user profile for role checking
const { data: profile, error: profileError } = await supabase
.from('profiles')
.select(`
role,
company_id,
company:companies(is_active)
`)
.eq('id', session.user.id)
.single()
if (profileError || !profile) {
return NextResponse.redirect(new URL('/login', req.url))
}
// Check role requirements
if (matchedRoute.roles && !matchedRoute.roles.includes(profile.role)) {
return NextResponse.redirect(new URL('/unauthorized', req.url))
}
// Check company requirements
if (matchedRoute.requireCompany) {
if (!profile.company_id || !profile.company?.is_active) {
return NextResponse.redirect(new URL('/setup-company', req.url))
}
}
}
return res
} catch (error) {
console.error('Middleware error:', error)
return NextResponse.redirect(new URL('/login', req.url))
}
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|public/).*)',
],
}
Schema Validation dengan Zod
Mari kita buat schema validation untuk form authentication:
// lib/validations/auth.ts
import { z } from 'zod'
export const loginSchema = z.object({
email: z
.string()
.min(1, 'Email is required')
.email('Please enter a valid email address'),
password: z
.string()
.min(1, 'Password is required')
.min(6, 'Password must be at least 6 characters'),
})
export const registerSchema = z.object({
fullName: z
.string()
.min(1, 'Full name is required')
.min(2, 'Full name must be at least 2 characters')
.max(100, 'Full name must be less than 100 characters'),
email: z
.string()
.min(1, 'Email is required')
.email('Please enter a valid email address'),
password: z
.string()
.min(1, 'Password is required')
.min(6, 'Password must be at least 6 characters')
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)/,
'Password must contain at least one uppercase letter, one lowercase letter, and one number'
),
confirmPassword: z
.string()
.min(1, 'Please confirm your password'),
terms: z
.boolean()
.refine(val => val === true, 'You must accept the terms and conditions'),
}).refine(data => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
})
export const forgotPasswordSchema = z.object({
email: z
.string()
.min(1, 'Email is required')
.email('Please enter a valid email address'),
})
export type LoginFormData = z.infer<typeof loginSchema>
export type RegisterFormData = z.infer<typeof registerSchema>
export type ForgotPasswordFormData = z.infer<typeof forgotPasswordSchema>
UI Components untuk Forms
Sebelum kita buat auth pages, mari kita buat beberapa reusable UI components:
// components/ui/input.tsx
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
// components/ui/button.tsx
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
Login Page Implementation
Sekarang mari kita buat login page dengan form validation yang robust:
// app/(auth)/login/page.tsx
'use client'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import Link from 'next/link'
import { Eye, EyeOff, Loader2 } from 'lucide-react'
import { useAuth } from '@/hooks/useAuth'
import { loginSchema, type LoginFormData } from '@/lib/validations/auth'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
export default function LoginPage() {
const [showPassword, setShowPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const { signIn } = useAuth()
const {
register,
handleSubmit,
formState: { errors },
setError,
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
})
const onSubmit = async (data: LoginFormData) => {
setIsLoading(true)
try {
const { error } = await signIn(data.email, data.password)
if (error) {
setError('root', {
type: 'manual',
message: error,
})
}
} catch (error) {
setError('root', {
type: 'manual',
message: 'An unexpected error occurred. Please try again.',
})
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Sign in to your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Or{' '}
<Link
href="/register"
className="font-medium text-blue-600 hover:text-blue-500"
>
create a new account
</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-4">
<div>
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
autoComplete="email"
placeholder="Enter your email"
{...register('email')}
className={errors.email ? 'border-red-500' : ''}
/>
{errors.email && (
<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
)}
</div>
<div>
<Label htmlFor="password">Password</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
placeholder="Enter your password"
{...register('password')}
className={errors.password ? 'border-red-500 pr-10' : 'pr-10'}
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-4 w-4 text-gray-400" />
) : (
<Eye className="h-4 w-4 text-gray-400" />
)}
</button>
</div>
{errors.password && (
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
)}
</div>
</div>
<div className="flex items-center justify-between">
<div className="text-sm">
<Link
href="/forgot-password"
className="font-medium text-blue-600 hover:text-blue-500"
>
Forgot your password?
</Link>
</div>
</div>
{errors.root && (
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<p className="text-sm text-red-600">{errors.root.message}</p>
</div>
)}
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Signing in...
</>
) : (
'Sign in'
)}
</Button>
</form>
</div>
</div>
)
}
Register Page Implementation
// app/(auth)/register/page.tsx
'use client'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import Link from 'next/link'
import { Eye, EyeOff, Loader2, Check } from 'lucide-react'
import { useAuth } from '@/hooks/useAuth'
import { registerSchema, type RegisterFormData } from '@/lib/validations/auth'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
export default function RegisterPage() {
const [showPassword, setShowPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const { signUp } = useAuth()
const {
register,
handleSubmit,
formState: { errors },
setError,
watch,
} = useForm<RegisterFormData>({
resolver: zodResolver(registerSchema),
})
const password = watch('password')
const onSubmit = async (data: RegisterFormData) => {
setIsLoading(true)
try {
const { error } = await signUp(data.email, data.password, data.fullName)
if (error) {
setError('root', {
type: 'manual',
message: error,
})
} else {
setIsSuccess(true)
}
} catch (error) {
setError('root', {
type: 'manual',
message: 'An unexpected error occurred. Please try again.',
})
} finally {
setIsLoading(false)
}
}
if (isSuccess) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div className="text-center">
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
<Check className="h-6 w-6 text-green-600" />
</div>
<h2 className="mt-6 text-3xl font-extrabold text-gray-900">
Check your email
</h2>
<p className="mt-2 text-sm text-gray-600">
We've sent a verification link to your email address. Please check your inbox and click the link to activate your account.
</p>
<div className="mt-6">
<Link
href="/login"
className="font-medium text-blue-600 hover:text-blue-500"
>
Back to sign in
</Link>
</div>
</div>
</div>
</div>
)
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Create your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Or{' '}
<Link
href="/login"
className="font-medium text-blue-600 hover:text-blue-500"
>
sign in to your existing account
</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-4">
<div>
<Label htmlFor="fullName">Full Name</Label>
<Input
id="fullName"
type="text"
autoComplete="name"
placeholder="Enter your full name"
{...register('fullName')}
className={errors.fullName ? 'border-red-500' : ''}
/>
{errors.fullName && (
<p className="mt-1 text-sm text-red-600">{errors.fullName.message}</p>
)}
</div>
<div>
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
autoComplete="email"
placeholder="Enter your email"
{...register('email')}
className={errors.email ? 'border-red-500' : ''}
/>
{errors.email && (
<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
)}
</div>
<div>
<Label htmlFor="password">Password</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
autoComplete="new-password"
placeholder="Create a password"
{...register('password')}
className={errors.password ? 'border-red-500 pr-10' : 'pr-10'}
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-4 w-4 text-gray-400" />
) : (
<Eye className="h-4 w-4 text-gray-400" />
)}
</button>
</div>
{errors.password && (
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
)}
{/* Password strength indicator */}
{password && (
<div className="mt-2 space-y-1">
<div className="text-xs text-gray-600">Password requirements:</div>
<div className="space-y-1 text-xs">
<div className={`flex items-center ${password.length >= 6 ? 'text-green-600' : 'text-gray-400'}`}>
<Check className={`h-3 w-3 mr-1 ${password.length >= 6 ? 'visible' : 'invisible'}`} />
At least 6 characters
</div>
<div className={`flex items-center ${/[A-Z]/.test(password) ? 'text-green-600' : 'text-gray-400'}`}>
<Check className={`h-3 w-3 mr-1 ${/[A-Z]/.test(password) ? 'visible' : 'invisible'}`} />
One uppercase letter
</div>
<div className={`flex items-center ${/[a-z]/.test(password) ? 'text-green-600' : 'text-gray-400'}`}>
<Check className={`h-3 w-3 mr-1 ${/[a-z]/.test(password) ? 'visible' : 'invisible'}`} />
One lowercase letter
</div>
<div className={`flex items-center ${/\\d/.test(password) ? 'text-green-600' : 'text-gray-400'}`}>
<Check className={`h-3 w-3 mr-1 ${/\\d/.test(password) ? 'visible' : 'invisible'}`} />
One number
</div>
</div>
</div>
)}
</div>
<div>
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
autoComplete="new-password"
placeholder="Confirm your password"
{...register('confirmPassword')}
className={errors.confirmPassword ? 'border-red-500' : ''}
/>
{errors.confirmPassword && (
<p className="mt-1 text-sm text-red-600">{errors.confirmPassword.message}</p>
)}
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="terms"
{...register('terms')}
/>
<Label htmlFor="terms" className="text-sm">
I agree to the{' '}
<Link href="/terms" className="text-blue-600 hover:text-blue-500">
Terms of Service
</Link>{' '}
and{' '}
<Link href="/privacy" className="text-blue-600 hover:text-blue-500">
Privacy Policy
</Link>
</Label>
</div>
{errors.terms && (
<p className="text-sm text-red-600">{errors.terms.message}</p>
)}
</div>
{errors.root && (
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<p className="text-sm text-red-600">{errors.root.message}</p>
</div>
)}
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating account...
</>
) : (
'Create account'
)}
</Button>
</form>
</div>
</div>
)
}
Protected Route Wrapper Components
Mari kita buat component wrapper untuk protect routes berdasarkan permissions:
// components/auth/ProtectedRoute.tsx
'use client'
import { useAuth } from '@/hooks/useAuth'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
import { Loader2 } from 'lucide-react'
interface ProtectedRouteProps {
children: React.ReactNode
roles?: string[]
permissions?: string[]
requireCompany?: boolean
fallback?: React.ReactNode
}
export function ProtectedRoute({
children,
roles,
permissions,
requireCompany = false,
fallback,
}: ProtectedRouteProps) {
const { user, profile, loading, hasRole, hasPermission } = useAuth()
const router = useRouter()
useEffect(() => {
if (!loading) {
// Redirect to login if not authenticated
if (!user) {
router.push('/login')
return
}
// Check role requirements
if (roles && !hasRole(roles)) {
router.push('/unauthorized')
return
}
// Check permission requirements
if (permissions && !permissions.every(permission => hasPermission(permission))) {
router.push('/unauthorized')
return
}
// Check company requirements
if (requireCompany && (!profile?.company_id || !profile.company?.is_active)) {
router.push('/setup-company')
return
}
}
}, [user, profile, loading, roles, permissions, requireCompany, hasRole, hasPermission, router])
// Show loading state
if (loading) {
return (
fallback || (
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
)
)
}
// Show children if all checks pass
if (user && profile) {
// Final checks before rendering
if (roles && !hasRole(roles)) return null
if (permissions && !permissions.every(permission => hasPermission(permission))) return null
if (requireCompany && (!profile.company_id || !profile.company?.is_active)) return null
return <>{children}</>
}
return null
}
// components/auth/RoleGuard.tsx
'use client'
import { useAuth } from '@/hooks/useAuth'
interface RoleGuardProps {
children: React.ReactNode
roles?: string[]
permissions?: string[]
fallback?: React.ReactNode
}
export function RoleGuard({
children,
roles,
permissions,
fallback = null,
}: RoleGuardProps) {
const { hasRole, hasPermission } = useAuth()
// Check role requirements
if (roles && !hasRole(roles)) {
return <>{fallback}</>
}
// Check permission requirements
if (permissions && !permissions.every(permission => hasPermission(permission))) {
return <>{fallback}</>
}
return <>{children}</>
}
Setup AuthProvider di Root Layout
Sekarang kita perlu wrap aplikasi dengan AuthProvider:
// app/layout.tsx
import { AuthProvider } from '@/hooks/useAuth'
import './globals.css'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<AuthProvider>
{children}
</AuthProvider>
</body>
</html>
)
}
Custom Claims dan User Metadata
Untuk advanced role management, kita bisa menambahkan custom claims ke user metadata. Buat function untuk update user metadata:
// lib/auth/claims.ts
import { createRouteHandlerSupabaseClient } from '@/lib/supabase/server'
export async function updateUserClaims(userId: string, claims: Record<string, any>) {
const supabase = createRouteHandlerSupabaseClient()
// Update user metadata dengan custom claims
const { error } = await supabase.auth.admin.updateUserById(userId, {
user_metadata: {
...claims,
},
})
if (error) throw error
}
export async function addUserToCompany(userId: string, companyId: string, role: string) {
const supabase = createRouteHandlerSupabaseClient()
// Update profile dengan company_id dan role
const { error } = await supabase
.from('profiles')
.update({
company_id: companyId,
role: role,
})
.eq('id', userId)
if (error) throw error
// Update user metadata
await updateUserClaims(userId, {
company_id: companyId,
role: role,
})
}
Dashboard Admin Modern dengan Shadcn/UI Components
Alright guys! Sekarang kita masuk ke bagian yang paling exciting - building dashboard admin yang modern dan responsive. Kita bakal menggunakan Shadcn/UI yang merupakan collection of copy-and-paste components yang sangat powerful dan customizable. Let's create something amazing!
Setup Shadcn/UI dan Dependencies
Mari kita mulai dengan install dan setup Shadcn/UI. Framework ini memberikan kita components yang beautifully designed dan fully accessible.
# Install Shadcn/UI
npx shadcn-ui@latest init
# Install additional dependencies yang kita butuhkan
npm install @tanstack/react-table @tanstack/react-query lucide-react date-fns class-variance-authority clsx tailwind-merge
Saat menjalankan shadcn-ui init, pilih konfigurasi berikut:
- Style: Default
- Base color: Slate
- CSS variables: Yes
- Tailwind config: Yes
- Import alias: @/components
- React Server Components: Yes
Sekarang install components yang kita butuhkan:
# Install Shadcn components yang diperlukan
npx shadcn-ui@latest add button input table dialog card dropdown-menu avatar badge separator sheet breadcrumb
Utility Functions untuk Styling
Sebelum kita mulai building components, mari setup utility functions untuk styling:
// lib/utils.ts
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatCurrency(amount: number, currency: string = 'IDR') {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: currency,
}).format(amount)
}
export function formatDate(date: string | Date) {
return new Intl.DateTimeFormat('id-ID', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(new Date(date))
}
export function formatDateTime(date: string | Date) {
return new Intl.DateTimeFormat('id-ID', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(date))
}
Dashboard Layout dengan Sidebar Navigation
Mari kita buat layout dashboard yang comprehensive dengan sidebar navigation yang responsive:
// app/(dashboard)/layout.tsx
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import {
Menu,
X,
Home,
Building2,
Users,
UserCheck,
Package,
ShoppingCart,
BarChart3,
Settings,
LogOut,
Bell,
Search
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'
import { Badge } from '@/components/ui/badge'
import { useAuth } from '@/hooks/useAuth'
import { ProtectedRoute } from '@/components/auth/ProtectedRoute'
import { Breadcrumb } from '@/components/ui/breadcrumb'
const navigation = [
{
name: 'Dashboard',
href: '/dashboard',
icon: Home,
roles: ['admin', 'owner', 'manager', 'employee'],
},
{
name: 'Laundries',
href: '/dashboard/laundries',
icon: Building2,
roles: ['admin', 'owner', 'manager'],
},
{
name: 'Employees',
href: '/dashboard/employees',
icon: UserCheck,
roles: ['admin', 'owner', 'manager'],
},
{
name: 'Customers',
href: '/dashboard/customers',
icon: Users,
roles: ['admin', 'owner', 'manager', 'employee'],
},
{
name: 'Orders',
href: '/dashboard/orders',
icon: ShoppingCart,
roles: ['admin', 'owner', 'manager', 'employee'],
},
{
name: 'Services',
href: '/dashboard/services',
icon: Package,
roles: ['admin', 'owner', 'manager'],
},
{
name: 'Analytics',
href: '/dashboard/analytics',
icon: BarChart3,
roles: ['admin', 'owner', 'manager'],
},
{
name: 'Settings',
href: '/dashboard/settings',
icon: Settings,
roles: ['admin', 'owner'],
},
]
function Sidebar({ className }: { className?: string }) {
const pathname = usePathname()
const { profile, hasRole } = useAuth()
return (
<div className={className}>
<div className="flex h-full max-h-screen flex-col gap-2">
{/* Logo */}
<div className="flex h-15 items-center border-b px-4 lg:h-[60px] lg:px-6">
<Link href="/dashboard" className="flex items-center gap-2 font-semibold">
<Package className="h-6 w-6" />
<span>LaundryPro</span>
</Link>
</div>
{/* Navigation */}
<div className="flex-1 overflow-auto">
<nav className="grid items-start px-2 text-sm font-medium lg:px-4">
{navigation.map((item) => {
if (!hasRole(item.roles)) return null
const isActive = pathname === item.href
return (
<Link
key={item.name}
href={item.href}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 transition-all hover:text-primary",
isActive
? "bg-muted text-primary"
: "text-muted-foreground"
)}
>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
)
})}
</nav>
</div>
{/* User Info */}
<div className="mt-auto p-4">
<div className="flex items-center gap-3 rounded-lg bg-muted p-3">
<Avatar className="h-8 w-8">
<AvatarImage src={profile?.avatar_url} />
<AvatarFallback>
{profile?.full_name?.split(' ').map(n => n[0]).join('') || 'U'}
</AvatarFallback>
</Avatar>
<div className="grid gap-1">
<p className="text-sm font-medium leading-none">
{profile?.full_name}
</p>
<p className="text-xs text-muted-foreground">
{profile?.company?.name}
</p>
</div>
</div>
</div>
</div>
</div>
)
}
function Header() {
const { profile, signOut } = useAuth()
const [notifications] = useState(3) // Mock notification count
return (
<header className="flex h-15 items-center gap-4 border-b bg-muted/40 px-4 lg:h-[60px] lg:px-6">
{/* Mobile menu */}
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" size="icon" className="shrink-0 md:hidden">
<Menu className="h-5 w-5" />
<span className="sr-only">Toggle navigation menu</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="flex flex-col">
<Sidebar />
</SheetContent>
</Sheet>
{/* Breadcrumb */}
<div className="w-full flex-1">
<DashboardBreadcrumb />
</div>
{/* Search */}
<div className="flex w-full max-w-sm items-center space-x-2">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Search..."
className="pl-8 md:w-[300px] lg:w-[400px]"
/>
</div>
</div>
{/* Notifications */}
<Button variant="outline" size="icon" className="relative">
<Bell className="h-4 w-4" />
{notifications > 0 && (
<Badge
variant="destructive"
className="absolute -top-2 -right-2 h-5 w-5 flex items-center justify-center p-0 text-xs"
>
{notifications}
</Badge>
)}
</Button>
{/* User Profile Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="icon" className="rounded-full">
<Avatar className="h-8 w-8">
<AvatarImage src={profile?.avatar_url} />
<AvatarFallback>
{profile?.full_name?.split(' ').map(n => n[0]).join('') || 'U'}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{profile?.full_name}</p>
<p className="text-xs leading-none text-muted-foreground">
{profile?.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/dashboard/profile">Profile</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/dashboard/settings">Settings</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={signOut} className="text-red-600">
<LogOut className="mr-2 h-4 w-4" />
<span>Log out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</header>
)
}
function DashboardBreadcrumb() {
const pathname = usePathname()
const generateBreadcrumbs = () => {
const paths = pathname.split('/').filter(Boolean)
const breadcrumbs = paths.map((path, index) => {
const href = '/' + paths.slice(0, index + 1).join('/')
const label = path.charAt(0).toUpperCase() + path.slice(1).replace('-', ' ')
return { href, label }
})
return breadcrumbs
}
const breadcrumbs = generateBreadcrumbs()
return (
<nav className="flex" aria-label="Breadcrumb">
<ol className="inline-flex items-center space-x-1 md:space-x-3">
{breadcrumbs.map((breadcrumb, index) => (
<li key={breadcrumb.href} className="inline-flex items-center">
{index > 0 && (
<svg
className="w-3 h-3 text-gray-400 mx-1"
aria-hidden="true"
xmlns="<http://www.w3.org/2000/svg>"
fill="none"
viewBox="0 0 6 10"
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="m1 9 4-4-4-4"
/>
</svg>
)}
<Link
href={breadcrumb.href}
className={cn(
"text-sm font-medium hover:text-blue-600",
index === breadcrumbs.length - 1
? "text-gray-500"
: "text-gray-700"
)}
>
{breadcrumb.label}
</Link>
</li>
))}
</ol>
</nav>
)
}
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<ProtectedRoute
roles={['admin', 'owner', 'manager', 'employee']}
requireCompany
>
<div className="grid min-h-screen w-full md:grid-cols-[220px_1fr] lg:grid-cols-[280px_1fr]">
{/* Desktop Sidebar */}
<Sidebar className="hidden border-r bg-muted/40 md:block" />
{/* Main Content */}
<div className="flex flex-col">
<Header />
<main className="flex flex-1 flex-col gap-4 p-4 lg:gap-6 lg:p-6">
{children}
</main>
</div>
</div>
</ProtectedRoute>
)
}
Data Table Component dengan Server-side Features
Sekarang mari kita buat data table component yang powerful dengan server-side pagination, sorting, dan filtering:
// components/ui/data-table.tsx
'use client'
import { useState } from 'react'
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
getSortedRowModel,
SortingState,
ColumnFiltersState,
getFilteredRowModel,
getPaginationRowModel,
} from '@tanstack/react-table'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Search,
Filter
} from 'lucide-react'
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
data: TData[]
searchKey?: string
searchPlaceholder?: string
filterableColumns?: {
key: string
title: string
options: { label: string; value: string }[]
}[]
onSearch?: (value: string) => void
onFilter?: (key: string, value: string) => void
pagination?: {
page: number
pageSize: number
total: number
onPageChange: (page: number) => void
onPageSizeChange: (pageSize: number) => void
}
loading?: boolean
}
export function DataTable<TData, TValue>({
columns,
data,
searchKey,
searchPlaceholder = "Search...",
filterableColumns = [],
onSearch,
onFilter,
pagination,
loading = false,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [globalFilter, setGlobalFilter] = useState("")
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: setGlobalFilter,
state: {
sorting,
columnFilters,
globalFilter,
},
})
const handleSearch = (value: string) => {
setGlobalFilter(value)
onSearch?.(value)
}
const handleFilter = (key: string, value: string) => {
const existingFilter = columnFilters.find(f => f.id === key)
if (existingFilter) {
setColumnFilters(prev =>
prev.map(f => f.id === key ? { ...f, value } : f)
)
} else {
setColumnFilters(prev => [...prev, { id: key, value }])
}
onFilter?.(key, value)
}
return (
<div className="space-y-4">
{/* Search and Filters */}
<div className="flex items-center justify-between">
<div className="flex flex-1 items-center space-x-2">
{/* Global Search */}
<div className="relative max-w-sm">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={searchPlaceholder}
value={globalFilter}
onChange={(e) => handleSearch(e.target.value)}
className="pl-8"
/>
</div>
{/* Column Filters */}
{filterableColumns.map((column) => (
<Select
key={column.key}
onValueChange={(value) => handleFilter(column.key, value)}
>
<SelectTrigger className="w-[180px]">
<Filter className="mr-2 h-4 w-4" />
<SelectValue placeholder={column.title} />
</SelectTrigger>
<SelectContent>
<SelectItem value="">All {column.title}</SelectItem>
{column.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
))}
</div>
</div>
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{loading ? (
Array.from({ length: 5 }).map((_, index) => (
<TableRow key={index}>
{columns.map((_, colIndex) => (
<TableCell key={colIndex}>
<div className="h-4 bg-muted animate-pulse rounded" />
</TableCell>
))}
</TableRow>
))
) : 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>
{/* Pagination */}
{pagination && (
<div className="flex items-center justify-between px-2">
<div className="flex-1 text-sm text-muted-foreground">
Showing {((pagination.page - 1) * pagination.pageSize) + 1} to{' '}
{Math.min(pagination.page * pagination.pageSize, pagination.total)} of{' '}
{pagination.total} entries
</div>
<div className="flex items-center space-x-6 lg:space-x-8">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium">Rows per page</p>
<Select
value={pagination.pageSize.toString()}
onValueChange={(value) => pagination.onPageSizeChange(Number(value))}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder={pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={pageSize.toString()}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => pagination.onPageChange(1)}
disabled={pagination.page <= 1}
>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => pagination.onPageChange(pagination.page - 1)}
disabled={pagination.page <= 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
Page {pagination.page} of {Math.ceil(pagination.total / pagination.pageSize)}
</div>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => pagination.onPageChange(pagination.page + 1)}
disabled={pagination.page >= Math.ceil(pagination.total / pagination.pageSize)}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => pagination.onPageChange(Math.ceil(pagination.total / pagination.pageSize))}
disabled={pagination.page >= Math.ceil(pagination.total / pagination.pageSize)}
>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
</div>
)
}
Reusable Form Components
Mari kita buat form components yang reusable untuk CRUD operations:
// components/forms/FormField.tsx
'use client'
import { forwardRef } from 'react'
import { UseFormReturn } from 'react-hook-form'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { cn } from '@/lib/utils'
interface BaseFieldProps {
name: string
label?: string
description?: string
required?: boolean
className?: string
form: UseFormReturn<any>
}
interface InputFieldProps extends BaseFieldProps {
type?: 'text' | 'email' | 'password' | 'number' | 'tel'
placeholder?: string
disabled?: boolean
}
interface TextareaFieldProps extends BaseFieldProps {
placeholder?: string
rows?: number
disabled?: boolean
}
interface SelectFieldProps extends BaseFieldProps {
placeholder?: string
options: { value: string; label: string }[]
disabled?: boolean
}
interface CheckboxFieldProps extends BaseFieldProps {
disabled?: boolean
}
export const FormField = forwardRef<HTMLDivElement, BaseFieldProps>(
({ name, label, description, required, className, form, children }, ref) => {
const error = form.formState.errors[name]?.message as string
return (
<div ref={ref} className={cn("space-y-2", className)}>
{label && (
<Label htmlFor={name} className={required ? "after:content-['*'] after:ml-0.5 after:text-red-500" : ""}>
{label}
</Label>
)}
{children}
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
</div>
)
}
)
FormField.displayName = "FormField"
export function InputField({
name,
label,
description,
required,
className,
form,
type = 'text',
placeholder,
disabled,
}: InputFieldProps) {
return (
<FormField
name={name}
label={label}
description={description}
required={required}
className={className}
form={form}
>
<Input
id={name}
type={type}
placeholder={placeholder}
disabled={disabled}
{...form.register(name)}
className={form.formState.errors[name] ? 'border-red-500' : ''}
/>
</FormField>
)
}
export function TextareaField({
name,
label,
description,
required,
className,
form,
placeholder,
rows = 3,
disabled,
}: TextareaFieldProps) {
return (
<FormField
name={name}
label={label}
description={description}
required={required}
className={className}
form={form}
>
<Textarea
id={name}
placeholder={placeholder}
rows={rows}
disabled={disabled}
{...form.register(name)}
className={form.formState.errors[name] ? 'border-red-500' : ''}
/>
</FormField>
)
}
export function SelectField({
name,
label,
description,
required,
className,
form,
placeholder,
options,
disabled,
}: SelectFieldProps) {
return (
<FormField
name={name}
label={label}
description={description}
required={required}
className={className}
form={form}
>
<Select
disabled={disabled}
onValueChange={(value) => form.setValue(name, value)}
defaultValue={form.getValues(name)}
>
<SelectTrigger className={form.formState.errors[name] ? 'border-red-500' : ''}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
)
}
export function CheckboxField({
name,
label,
description,
required,
className,
form,
disabled,
}: CheckboxFieldProps) {
return (
<FormField
name={name}
label={label}
description={description}
required={required}
className={className}
form={form}
>
<div className="flex items-center space-x-2">
<Checkbox
id={name}
disabled={disabled}
checked={form.watch(name)}
onCheckedChange={(checked) => form.setValue(name, checked)}
/>
{label && (
<Label
htmlFor={name}
className="text-sm font-normal cursor-pointer"
>
{label}
</Label>
)}
</div>
</FormField>
)
}
Modal Form Component
Mari kita buat modal form component yang reusable:
// components/forms/ModalForm.tsx
'use client'
import { useState } from 'react'
import { UseFormReturn } from 'react-hook-form'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Loader2 } from 'lucide-react'
interface ModalFormProps {
trigger: React.ReactNode
title: string
description?: string
form: UseFormReturn<any>
onSubmit: (data: any) => Promise<void>
loading?: boolean
children: React.ReactNode
submitLabel?: string
cancelLabel?: string
open?: boolean
onOpenChange?: (open: boolean) => void
}
export function ModalForm({
trigger,
title,
description,
form,
onSubmit,
loading = false,
children,
submitLabel = 'Save',
cancelLabel = 'Cancel',
open,
onOpenChange,
}: ModalFormProps) {
const [isOpen, setIsOpen] = useState(false)
const handleOpenChange = (newOpen: boolean) => {
if (onOpenChange) {
onOpenChange(newOpen)
} else {
setIsOpen(newOpen)
}
if (!newOpen) {
form.reset()
}
}
const handleSubmit = async (data: any) => {
try {
await onSubmit(data)
handleOpenChange(false)
} catch (error) {
// Error handling sudah dilakukan di parent component
}
}
const isControlled = open !== undefined
return (
<Dialog
open={isControlled ? open : isOpen}
onOpenChange={handleOpenChange}
>
<DialogTrigger asChild>
{trigger}
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && (
<DialogDescription>{description}</DialogDescription>
)}
</DialogHeader>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
{children}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={loading}
>
{cancelLabel}
</Button>
<Button type="submit" disabled={loading}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
submitLabel
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
Schema Validation untuk CRUD Operations
Mari kita buat schemas untuk different entities:
// lib/validations/entities.ts
import { z } from 'zod'
export const laundrySchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
code: z.string().min(1, 'Code is required').max(10),
address: z.string().min(1, 'Address is required'),
city: z.string().min(1, 'City is required'),
province: z.string().min(1, 'Province is required'),
postal_code: z.string().optional(),
phone: z.string().min(1, 'Phone is required'),
email: z.string().email().optional().or(z.literal('')),
capacity: z.number().min(1, 'Capacity must be at least 1'),
is_active: z.boolean().default(true),
})
export const employeeSchema = z.object({
profile_id: z.string().uuid('Invalid profile ID'),
laundry_id: z.string().uuid('Invalid laundry ID'),
employee_code: z.string().min(1, 'Employee code is required'),
position: z.string().min(1, 'Position is required'),
salary: z.number().min(0, 'Salary must be positive').optional(),
commission_rate: z.number().min(0).max(100, 'Commission rate must be between 0-100').default(0),
hire_date: z.string().min(1, 'Hire date is required'),
is_active: z.boolean().default(true),
})
export const customerSchema = z.object({
customer_code: z.string().min(1, 'Customer code is required'),
full_name: z.string().min(1, 'Full name is required').max(100),
email: z.string().email().optional().or(z.literal('')),
phone: z.string().min(1, 'Phone is required'),
address: z.string().optional(),
city: z.string().optional(),
postal_code: z.string().optional(),
date_of_birth: z.string().optional(),
gender: z.enum(['male', 'female', 'other']).optional(),
membership_type: z.enum(['regular', 'silver', 'gold', 'platinum']).default('regular'),
notes: z.string().optional(),
is_active: z.boolean().default(true),
})
export const serviceTypeSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
description: z.string().optional(),
price_per_kg: z.number().min(0, 'Price must be positive').optional(),
price_per_item: z.number().min(0, 'Price must be positive').optional(),
pricing_type: z.enum(['per_kg', 'per_item', 'flat_rate']),
estimated_duration: z.number().min(1, 'Duration must be at least 1 hour'),
is_active: z.boolean().default(true),
sort_order: z.number().default(0),
})
export type LaundryFormData = z.infer<typeof laundrySchema>
export type EmployeeFormData = z.infer<typeof employeeSchema>
export type CustomerFormData = z.infer<typeof customerSchema>
export type ServiceTypeFormData = z.infer<typeof serviceTypeSchema>
Example Implementation - Laundries Page
Mari kita buat contoh implementasi untuk halaman laundries:
// app/(dashboard)/laundries/page.tsx
'use client'
import { useState, useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { Plus, Edit, Trash2, MapPin } from 'lucide-react'
import { ColumnDef } from '@tanstack/react-table'
import { DataTable } from '@/components/ui/data-table'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import { ModalForm } from '@/components/forms/ModalForm'
import { InputField, SelectField, CheckboxField } from '@/components/forms/FormField'
import { laundrySchema, type LaundryFormData } from '@/lib/validations/entities'
import { formatDate } from '@/lib/utils'
import { useAuth } from '@/hooks/useAuth'
interface Laundry {
id: string
name: string
code: string
address: string
city: string
province: string
phone: string
email?: string
capacity: number
is_active: boolean
created_at: string
_count: {
employees: number
orders: number
}
}
export default function LaundriesPage() {
const [laundries, setLaundries] = useState<Laundry[]>([])
const [loading, setLoading] = useState(true)
const [submitLoading, setSubmitLoading] = useState(false)
const [pagination, setPagination] = useState({
page: 1,
pageSize: 10,
total: 0,
})
const { hasPermission } = useAuth()
const canManage = hasPermission('laundry.manage')
const form = useForm<LaundryFormData>({
resolver: zodResolver(laundrySchema),
defaultValues: {
is_active: true,
capacity: 100,
},
})
// Fetch laundries data
const fetchLaundries = async () => {
setLoading(true)
try {
// Mock data fetch - replace with actual API call
const mockData: Laundry[] = [
{
id: '1',
name: 'LaundryPro Senayan',
code: 'SNY01',
address: 'Jl. Senayan Raya No. 123',
city: 'Jakarta Pusat',
province: 'DKI Jakarta',
phone: '021-1234567',
email: '[email protected]',
capacity: 150,
is_active: true,
created_at: '2024-01-15T10:00:00Z',
_count: {
employees: 8,
orders: 245,
},
},
// Add more mock data...
]
setLaundries(mockData)
setPagination(prev => ({ ...prev, total: mockData.length }))
} catch (error) {
console.error('Error fetching laundries:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchLaundries()
}, [pagination.page, pagination.pageSize])
const handleSubmit = async (data: LaundryFormData) => {
setSubmitLoading(true)
try {
// Mock API call - replace with actual API
console.log('Creating laundry:', data)
await new Promise(resolve => setTimeout(resolve, 1000))
// Refresh data
await fetchLaundries()
form.reset()
} catch (error) {
console.error('Error creating laundry:', error)
throw error
} finally {
setSubmitLoading(false)
}
}
const columns: ColumnDef<Laundry>[] = [
{
accessorKey: 'code',
header: 'Code',
cell: ({ row }) => (
<div className="font-medium">{row.getValue('code')}</div>
),
},
{
accessorKey: 'name',
header: 'Name',
},
{
accessorKey: 'address',
header: 'Location',
cell: ({ row }) => (
<div className="max-w-[200px]">
<div className="font-medium">{row.original.address}</div>
<div className="text-sm text-muted-foreground">
{row.original.city}, {row.original.province}
</div>
</div>
),
},
{
accessorKey: 'capacity',
header: 'Capacity',
cell: ({ row }) => (
<div className="text-center">{row.getValue('capacity')} orders/day</div>
),
},
{
accessorKey: '_count.employees',
header: 'Staff',
cell: ({ row }) => (
<div className="text-center">{row.original._count.employees} employees</div>
),
},
{
accessorKey: 'is_active',
header: 'Status',
cell: ({ row }) => (
<Badge variant={row.getValue('is_active') ? 'default' : 'secondary'}>
{row.getValue('is_active') ? 'Active' : 'Inactive'}
</Badge>
),
},
{
id: 'actions',
header: 'Actions',
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => console.log('View', row.original.id)}
>
<MapPin className="h-4 w-4" />
</Button>
{canManage && (
<>
<Button
variant="ghost"
size="icon"
onClick={() => console.log('Edit', row.original.id)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => console.log('Delete', row.original.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
</div>
),
},
]
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Laundries</h1>
<p className="text-muted-foreground">
Manage your laundry branches and locations
</p>
</div>
{canManage && (
<ModalForm
trigger={
<Button>
<Plus className="mr-2 h-4 w-4" />
Add Laundry
</Button>
}
title="Add New Laundry"
description="Create a new laundry branch location"
form={form}
onSubmit={handleSubmit}
loading={submitLoading}
>
<div className="grid grid-cols-2 gap-4">
<InputField
name="name"
label="Name"
placeholder="Enter laundry name"
required
form={form}
/>
<InputField
name="code"
label="Code"
placeholder="Enter branch code"
required
form={form}
/>
</div>
<InputField
name="address"
label="Address"
placeholder="Enter complete address"
required
form={form}
/>
<div className="grid grid-cols-2 gap-4">
<InputField
name="city"
label="City"
placeholder="Enter city"
required
form={form}
/>
<InputField
name="province"
label="Province"
placeholder="Enter province"
required
form={form}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<InputField
name="phone"
label="Phone"
placeholder="Enter phone number"
required
form={form}
/>
<InputField
name="email"
label="Email"
type="email"
placeholder="Enter email address"
form={form}
/>
</div>
<InputField
name="capacity"
label="Daily Capacity"
type="number"
placeholder="Enter daily order capacity"
required
form={form}
/>
<CheckboxField
name="is_active"
label="Active"
form={form}
/>
</ModalForm>
)}
</div>
<Card>
<CardHeader>
<CardTitle>Laundry Branches</CardTitle>
</CardHeader>
<CardContent>
<DataTable
columns={columns}
data={laundries}
searchPlaceholder="Search laundries..."
filterableColumns={[
{
key: 'is_active',
title: 'Status',
options: [
{ label: 'Active', value: 'true' },
{ label: 'Inactive', value: 'false' },
],
},
]}
pagination={{
page: pagination.page,
pageSize: pagination.pageSize,
total: pagination.total,
onPageChange: (page) => setPagination(prev => ({ ...prev, page })),
onPageSizeChange: (pageSize) => setPagination(prev => ({ ...prev, pageSize })),
}}
loading={loading}
/>
</CardContent>
</Card>
</div>
)
}
Sistem Role dan Permission Management yang Granular
Oke teman-teman! Sekarang kita bakal build sistem role dan permission management yang sangat granular dan powerful. Ini crucial banget untuk SaaS application karena kita perlu ensure bahwa setiap user hanya bisa akses fitur sesuai dengan level mereka. Mari kita design system yang scalable dan maintainable!
Definisi Roles dan Permission Types
Pertama-tama, mari kita definisikan type definitions yang akan jadi foundation dari permission system kita:
// types/auth.ts
export enum UserRole {
SUPER_ADMIN = 'super_admin',
COMPANY_OWNER = 'company_owner',
LAUNDRY_MANAGER = 'laundry_manager',
CASHIER = 'cashier',
CUSTOMER = 'customer',
}
export enum PermissionAction {
CREATE = 'create',
READ = 'read',
UPDATE = 'update',
DELETE = 'delete',
MANAGE = 'manage', // Full CRUD access
}
export enum PermissionResource {
// System level
SYSTEM = 'system',
COMPANIES = 'companies',
SUBSCRIPTIONS = 'subscriptions',
BILLING = 'billing',
// Company level
COMPANY = 'company',
LAUNDRIES = 'laundries',
EMPLOYEES = 'employees',
// Laundry level
CUSTOMERS = 'customers',
ORDERS = 'orders',
SERVICES = 'services',
TRANSACTIONS = 'transactions',
// Analytics & Reports
ANALYTICS = 'analytics',
REPORTS = 'reports',
// Settings
SETTINGS = 'settings',
PROFILE = 'profile',
}
export interface Permission {
resource: PermissionResource
action: PermissionAction
conditions?: Record<string, any>
}
export interface UserPermissions {
role: UserRole
permissions: Permission[]
companyId?: string
laundryIds?: string[]
}
export interface AuthUser {
id: string
email: string
full_name: string
avatar_url?: string
role: UserRole
company_id?: string
laundry_ids?: string[]
permissions: UserPermissions
is_active: boolean
created_at: string
updated_at: string
}
Permission Matrix Configuration
Sekarang mari kita buat permission matrix yang define akses setiap role:
// lib/permissions/permission-matrix.ts
import { UserRole, PermissionAction, PermissionResource, Permission } from '@/types/auth'
type PermissionMatrix = Record<UserRole, Permission[]>
export const PERMISSION_MATRIX: PermissionMatrix = {
[UserRole.SUPER_ADMIN]: [
// System level - full access
{ resource: PermissionResource.SYSTEM, action: PermissionAction.MANAGE },
{ resource: PermissionResource.COMPANIES, action: PermissionAction.MANAGE },
{ resource: PermissionResource.SUBSCRIPTIONS, action: PermissionAction.MANAGE },
{ resource: PermissionResource.BILLING, action: PermissionAction.MANAGE },
// Company level - cross-company access
{ resource: PermissionResource.COMPANY, action: PermissionAction.MANAGE },
{ resource: PermissionResource.LAUNDRIES, action: PermissionAction.MANAGE },
{ resource: PermissionResource.EMPLOYEES, action: PermissionAction.MANAGE },
// Laundry level - all laundries
{ resource: PermissionResource.CUSTOMERS, action: PermissionAction.MANAGE },
{ resource: PermissionResource.ORDERS, action: PermissionAction.MANAGE },
{ resource: PermissionResource.SERVICES, action: PermissionAction.MANAGE },
{ resource: PermissionResource.TRANSACTIONS, action: PermissionAction.MANAGE },
// Analytics & Reports - global access
{ resource: PermissionResource.ANALYTICS, action: PermissionAction.READ },
{ resource: PermissionResource.REPORTS, action: PermissionAction.MANAGE },
// Settings - system wide
{ resource: PermissionResource.SETTINGS, action: PermissionAction.MANAGE },
{ resource: PermissionResource.PROFILE, action: PermissionAction.MANAGE },
],
[UserRole.COMPANY_OWNER]: [
// Company level - own company only
{
resource: PermissionResource.COMPANY,
action: PermissionAction.MANAGE,
conditions: { own_company: true }
},
{
resource: PermissionResource.LAUNDRIES,
action: PermissionAction.MANAGE,
conditions: { own_company: true }
},
{
resource: PermissionResource.EMPLOYEES,
action: PermissionAction.MANAGE,
conditions: { own_company: true }
},
// Subscriptions - own company subscription
{
resource: PermissionResource.SUBSCRIPTIONS,
action: PermissionAction.READ,
conditions: { own_company: true }
},
{
resource: PermissionResource.BILLING,
action: PermissionAction.MANAGE,
conditions: { own_company: true }
},
// Laundry level - all company laundries
{
resource: PermissionResource.CUSTOMERS,
action: PermissionAction.MANAGE,
conditions: { own_company: true }
},
{
resource: PermissionResource.ORDERS,
action: PermissionAction.MANAGE,
conditions: { own_company: true }
},
{
resource: PermissionResource.SERVICES,
action: PermissionAction.MANAGE,
conditions: { own_company: true }
},
{
resource: PermissionResource.TRANSACTIONS,
action: PermissionAction.READ,
conditions: { own_company: true }
},
// Analytics & Reports - company level
{
resource: PermissionResource.ANALYTICS,
action: PermissionAction.READ,
conditions: { own_company: true }
},
{
resource: PermissionResource.REPORTS,
action: PermissionAction.READ,
conditions: { own_company: true }
},
// Settings - company settings only
{
resource: PermissionResource.SETTINGS,
action: PermissionAction.MANAGE,
conditions: { own_company: true }
},
{ resource: PermissionResource.PROFILE, action: PermissionAction.MANAGE },
],
[UserRole.LAUNDRY_MANAGER]: [
// Company level - read only
{
resource: PermissionResource.COMPANY,
action: PermissionAction.READ,
conditions: { own_company: true }
},
{
resource: PermissionResource.LAUNDRIES,
action: PermissionAction.READ,
conditions: { own_company: true }
},
// Employees - manage within assigned laundries
{
resource: PermissionResource.EMPLOYEES,
action: PermissionAction.MANAGE,
conditions: { own_laundries: true }
},
// Laundry operations - assigned laundries only
{
resource: PermissionResource.CUSTOMERS,
action: PermissionAction.MANAGE,
conditions: { own_laundries: true }
},
{
resource: PermissionResource.ORDERS,
action: PermissionAction.MANAGE,
conditions: { own_laundries: true }
},
{
resource: PermissionResource.SERVICES,
action: PermissionAction.READ,
conditions: { own_company: true }
},
{
resource: PermissionResource.TRANSACTIONS,
action: PermissionAction.READ,
conditions: { own_laundries: true }
},
// Analytics & Reports - laundry level
{
resource: PermissionResource.ANALYTICS,
action: PermissionAction.READ,
conditions: { own_laundries: true }
},
{
resource: PermissionResource.REPORTS,
action: PermissionAction.READ,
conditions: { own_laundries: true }
},
// Profile only
{ resource: PermissionResource.PROFILE, action: PermissionAction.MANAGE },
],
[UserRole.CASHIER]: [
// Customers - basic operations
{
resource: PermissionResource.CUSTOMERS,
action: PermissionAction.CREATE,
conditions: { own_laundries: true }
},
{
resource: PermissionResource.CUSTOMERS,
action: PermissionAction.READ,
conditions: { own_laundries: true }
},
{
resource: PermissionResource.CUSTOMERS,
action: PermissionAction.UPDATE,
conditions: { own_laundries: true }
},
// Orders - full CRUD for own laundry
{
resource: PermissionResource.ORDERS,
action: PermissionAction.MANAGE,
conditions: { own_laundries: true }
},
// Services - read only
{
resource: PermissionResource.SERVICES,
action: PermissionAction.READ,
conditions: { own_company: true }
},
// Transactions - create and read
{
resource: PermissionResource.TRANSACTIONS,
action: PermissionAction.CREATE,
conditions: { own_laundries: true }
},
{
resource: PermissionResource.TRANSACTIONS,
action: PermissionAction.READ,
conditions: { own_laundries: true }
},
// Profile only
{ resource: PermissionResource.PROFILE, action: PermissionAction.MANAGE },
],
[UserRole.CUSTOMER]: [
// Orders - read own orders only
{
resource: PermissionResource.ORDERS,
action: PermissionAction.READ,
conditions: { own_orders: true }
},
// Services - read pricing
{
resource: PermissionResource.SERVICES,
action: PermissionAction.READ,
conditions: { public_info: true }
},
// Profile only
{ resource: PermissionResource.PROFILE, action: PermissionAction.MANAGE },
],
}
// Helper function untuk get permissions berdasarkan role
export function getPermissionsByRole(role: UserRole): Permission[] {
return PERMISSION_MATRIX[role] || []
}
// Helper function untuk check specific permission
export function hasPermission(
userPermissions: Permission[],
resource: PermissionResource,
action: PermissionAction,
context?: Record<string, any>
): boolean {
return userPermissions.some(permission => {
// Check resource dan action match
const resourceMatch = permission.resource === resource || permission.resource === PermissionResource.SYSTEM
const actionMatch = permission.action === action || permission.action === PermissionAction.MANAGE
if (!resourceMatch || !actionMatch) return false
// Check conditions jika ada
if (permission.conditions && context) {
return Object.entries(permission.conditions).every(([key, value]) => {
return context[key] === value
})
}
return true
})
}
Custom Hooks untuk Permission Management
Mari kita buat custom hooks yang akan digunakan throughout aplikasi untuk check permissions:
// hooks/usePermissions.ts
'use client'
import { useContext, createContext, useCallback } from 'react'
import { useAuth } from '@/hooks/useAuth'
import {
PermissionAction,
PermissionResource,
Permission,
UserRole
} from '@/types/auth'
import {
getPermissionsByRole,
hasPermission as checkPermission
} from '@/lib/permissions/permission-matrix'
interface PermissionContextType {
userPermissions: Permission[]
hasPermission: (resource: PermissionResource, action: PermissionAction, context?: Record<string, any>) => boolean
hasRole: (roles: UserRole | UserRole[]) => boolean
hasAnyPermission: (checks: Array<{ resource: PermissionResource; action: PermissionAction; context?: Record<string, any> }>) => boolean
hasAllPermissions: (checks: Array<{ resource: PermissionResource; action: PermissionAction; context?: Record<string, any> }>) => boolean
canAccess: (resourcePath: string) => boolean
getUserContext: () => Record<string, any>
}
const PermissionContext = createContext<PermissionContextType | undefined>(undefined)
export function PermissionProvider({ children }: { children: React.ReactNode }) {
const { profile, user } = useAuth()
const userPermissions = profile ? getPermissionsByRole(profile.role as UserRole) : []
const getUserContext = useCallback(() => {
if (!profile || !user) return {}
return {
user_id: user.id,
company_id: profile.company_id,
laundry_ids: profile.laundry_ids || [],
role: profile.role,
own_company: true, // Always true for user's own company context
own_laundries: true, // True for laundries user is assigned to
own_orders: true, // True for customer's own orders
public_info: true, // Public information access
}
}, [profile, user])
const hasPermission = useCallback((
resource: PermissionResource,
action: PermissionAction,
context?: Record<string, any>
) => {
if (!profile) return false
const userContext = { ...getUserContext(), ...context }
return checkPermission(userPermissions, resource, action, userContext)
}, [userPermissions, profile, getUserContext])
const hasRole = useCallback((roles: UserRole | UserRole[]) => {
if (!profile) return false
const roleArray = Array.isArray(roles) ? roles : [roles]
return roleArray.includes(profile.role as UserRole)
}, [profile])
const hasAnyPermission = useCallback((
checks: Array<{ resource: PermissionResource; action: PermissionAction; context?: Record<string, any> }>
) => {
return checks.some(check => hasPermission(check.resource, check.action, check.context))
}, [hasPermission])
const hasAllPermissions = useCallback((
checks: Array<{ resource: PermissionResource; action: PermissionAction; context?: Record<string, any> }>
) => {
return checks.every(check => hasPermission(check.resource, check.action, check.context))
}, [hasPermission])
const canAccess = useCallback((resourcePath: string) => {
// Map resource paths to permissions
const pathPermissionMap: Record<string, { resource: PermissionResource; action: PermissionAction }> = {
'/dashboard': { resource: PermissionResource.SYSTEM, action: PermissionAction.READ },
'/dashboard/companies': { resource: PermissionResource.COMPANIES, action: PermissionAction.READ },
'/dashboard/laundries': { resource: PermissionResource.LAUNDRIES, action: PermissionAction.READ },
'/dashboard/employees': { resource: PermissionResource.EMPLOYEES, action: PermissionAction.READ },
'/dashboard/customers': { resource: PermissionResource.CUSTOMERS, action: PermissionAction.READ },
'/dashboard/orders': { resource: PermissionResource.ORDERS, action: PermissionAction.READ },
'/dashboard/services': { resource: PermissionResource.SERVICES, action: PermissionAction.READ },
'/dashboard/analytics': { resource: PermissionResource.ANALYTICS, action: PermissionAction.READ },
'/dashboard/settings': { resource: PermissionResource.SETTINGS, action: PermissionAction.READ },
'/dashboard/billing': { resource: PermissionResource.BILLING, action: PermissionAction.READ },
}
const permission = pathPermissionMap[resourcePath]
if (!permission) return true // Allow access to unmapped paths
return hasPermission(permission.resource, permission.action)
}, [hasPermission])
const value = {
userPermissions,
hasPermission,
hasRole,
hasAnyPermission,
hasAllPermissions,
canAccess,
getUserContext,
}
return (
<PermissionContext.Provider value={value}>
{children}
</PermissionContext.Provider>
)
}
export function usePermissions() {
const context = useContext(PermissionContext)
if (context === undefined) {
throw new Error('usePermissions must be used within a PermissionProvider')
}
return context
}
// Shorthand hooks untuk common use cases
export function useHasPermission(
resource: PermissionResource,
action: PermissionAction,
context?: Record<string, any>
) {
const { hasPermission } = usePermissions()
return hasPermission(resource, action, context)
}
export function useHasRole(roles: UserRole | UserRole[]) {
const { hasRole } = usePermissions()
return hasRole(roles)
}
export function useCanAccess(resourcePath: string) {
const { canAccess } = usePermissions()
return canAccess(resourcePath)
}
Higher-Order Components untuk Permission-based Rendering
Sekarang mari kita buat HOCs dan wrapper components untuk conditional rendering:
// components/auth/withPermission.tsx
'use client'
import React from 'react'
import { usePermissions } from '@/hooks/usePermissions'
import { PermissionAction, PermissionResource, UserRole } from '@/types/auth'
interface WithPermissionProps {
resource?: PermissionResource
action?: PermissionAction
roles?: UserRole | UserRole[]
context?: Record<string, any>
fallback?: React.ReactNode
loading?: React.ReactNode
}
// HOC untuk wrap components dengan permission check
export function withPermission<P extends object>(
Component: React.ComponentType<P>,
permissionConfig: WithPermissionProps
) {
const WrappedComponent = (props: P) => {
const { hasPermission, hasRole } = usePermissions()
// Check role requirements
if (permissionConfig.roles && !hasRole(permissionConfig.roles)) {
return <>{permissionConfig.fallback || null}</>
}
// Check permission requirements
if (permissionConfig.resource && permissionConfig.action) {
if (!hasPermission(permissionConfig.resource, permissionConfig.action, permissionConfig.context)) {
return <>{permissionConfig.fallback || null}</>
}
}
return <Component {...props} />
}
WrappedComponent.displayName = `withPermission(${Component.displayName || Component.name})`
return WrappedComponent
}
// Component wrapper untuk permission-based rendering
interface PermissionGuardProps extends WithPermissionProps {
children: React.ReactNode
}
export function PermissionGuard({
children,
resource,
action,
roles,
context,
fallback = null,
}: PermissionGuardProps) {
const { hasPermission, hasRole } = usePermissions()
// Check role requirements
if (roles && !hasRole(roles)) {
return <>{fallback}</>
}
// Check permission requirements
if (resource && action && !hasPermission(resource, action, context)) {
return <>{fallback}</>
}
return <>{children}</>
}
// Specific guards untuk common use cases
interface RoleGuardProps {
roles: UserRole | UserRole[]
children: React.ReactNode
fallback?: React.ReactNode
}
export function RoleGuard({ roles, children, fallback = null }: RoleGuardProps) {
return (
<PermissionGuard roles={roles} fallback={fallback}>
{children}
</PermissionGuard>
)
}
interface ActionGuardProps {
resource: PermissionResource
action: PermissionAction
children: React.ReactNode
context?: Record<string, any>
fallback?: React.ReactNode
}
export function ActionGuard({
resource,
action,
children,
context,
fallback = null
}: ActionGuardProps) {
return (
<PermissionGuard
resource={resource}
action={action}
context={context}
fallback={fallback}
>
{children}
</PermissionGuard>
)
}
// Component untuk show different content berdasarkan role
interface RoleSwitchProps {
role: UserRole
children: React.ReactNode
}
interface RoleContentProps {
children: React.ReactNode
fallback?: React.ReactNode
}
export function RoleSwitch({ role, children }: RoleSwitchProps) {
const { hasRole } = usePermissions()
if (!hasRole(role)) return null
return <>{children}</>
}
// Usage:
// <RoleContent fallback={<div>No access</div>}>
// <RoleSwitch role={UserRole.SUPER_ADMIN}>Admin Content</RoleSwitch>
// <RoleSwitch role={UserRole.COMPANY_OWNER}>Owner Content</RoleSwitch>
// </RoleContent>
export function RoleContent({ children, fallback }: RoleContentProps) {
const childrenArray = React.Children.toArray(children)
const hasValidRole = childrenArray.some(child => {
if (React.isValidElement(child) && child.type === RoleSwitch) {
// This would need more sophisticated checking in real implementation
return true
}
return false
})
return hasValidRole ? <>{children}</> : <>{fallback}</>
}
Enhanced Middleware untuk API Route Protection
Mari kita update middleware untuk include permission checking:
// middleware.ts (Enhanced version)
import { createMiddlewareClient } from '@supabase/auth-helpers-nextjs'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import {
PermissionResource,
PermissionAction,
UserRole
} from '@/types/auth'
import {
getPermissionsByRole,
hasPermission
} from '@/lib/permissions/permission-matrix'
interface RoutePermission {
resource: PermissionResource
action: PermissionAction
context?: Record<string, any>
}
interface ProtectedRoute {
path: string
methods?: string[]
permissions?: RoutePermission[]
roles?: UserRole[]
requireCompany?: boolean
public?: boolean
}
// Define API route permissions
const API_ROUTE_PERMISSIONS: ProtectedRoute[] = [
// Company management
{
path: '/api/companies',
methods: ['GET'],
permissions: [{ resource: PermissionResource.COMPANIES, action: PermissionAction.READ }],
},
{
path: '/api/companies',
methods: ['POST'],
permissions: [{ resource: PermissionResource.COMPANIES, action: PermissionAction.CREATE }],
},
{
path: '/api/companies/[id]',
methods: ['PUT', 'PATCH'],
permissions: [{ resource: PermissionResource.COMPANIES, action: PermissionAction.UPDATE }],
},
{
path: '/api/companies/[id]',
methods: ['DELETE'],
permissions: [{ resource: PermissionResource.COMPANIES, action: PermissionAction.DELETE }],
},
// Laundries management
{
path: '/api/laundries',
methods: ['GET'],
permissions: [{ resource: PermissionResource.LAUNDRIES, action: PermissionAction.READ }],
requireCompany: true,
},
{
path: '/api/laundries',
methods: ['POST'],
permissions: [{ resource: PermissionResource.LAUNDRIES, action: PermissionAction.CREATE }],
requireCompany: true,
},
// Orders management
{
path: '/api/orders',
methods: ['GET'],
permissions: [{ resource: PermissionResource.ORDERS, action: PermissionAction.READ }],
requireCompany: true,
},
{
path: '/api/orders',
methods: ['POST'],
permissions: [{ resource: PermissionResource.ORDERS, action: PermissionAction.CREATE }],
requireCompany: true,
},
{
path: '/api/orders/[id]',
methods: ['PUT', 'PATCH'],
permissions: [{ resource: PermissionResource.ORDERS, action: PermissionAction.UPDATE }],
requireCompany: true,
},
// Analytics (read-only)
{
path: '/api/analytics',
methods: ['GET'],
permissions: [{ resource: PermissionResource.ANALYTICS, action: PermissionAction.READ }],
requireCompany: true,
},
// Billing and subscriptions
{
path: '/api/billing',
methods: ['GET'],
permissions: [{ resource: PermissionResource.BILLING, action: PermissionAction.READ }],
requireCompany: true,
},
{
path: '/api/subscriptions',
methods: ['POST', 'PUT'],
permissions: [{ resource: PermissionResource.SUBSCRIPTIONS, action: PermissionAction.MANAGE }],
roles: [UserRole.SUPER_ADMIN, UserRole.COMPANY_OWNER],
},
// Public routes
{
path: '/api/auth',
public: true,
},
{
path: '/api/webhooks',
public: true,
},
]
// Page route permissions
const PAGE_ROUTE_PERMISSIONS: ProtectedRoute[] = [
{
path: '/dashboard',
roles: [UserRole.SUPER_ADMIN, UserRole.COMPANY_OWNER, UserRole.LAUNDRY_MANAGER, UserRole.CASHIER],
requireCompany: true,
},
{
path: '/dashboard/companies',
roles: [UserRole.SUPER_ADMIN],
},
{
path: '/dashboard/laundries',
permissions: [{ resource: PermissionResource.LAUNDRIES, action: PermissionAction.READ }],
requireCompany: true,
},
{
path: '/dashboard/employees',
permissions: [{ resource: PermissionResource.EMPLOYEES, action: PermissionAction.READ }],
requireCompany: true,
},
{
path: '/dashboard/orders',
permissions: [{ resource: PermissionResource.ORDERS, action: PermissionAction.READ }],
requireCompany: true,
},
{
path: '/dashboard/analytics',
permissions: [{ resource: PermissionResource.ANALYTICS, action: PermissionAction.READ }],
requireCompany: true,
},
{
path: '/dashboard/billing',
permissions: [{ resource: PermissionResource.BILLING, action: PermissionAction.READ }],
requireCompany: true,
},
{
path: '/dashboard/settings',
permissions: [{ resource: PermissionResource.SETTINGS, action: PermissionAction.READ }],
requireCompany: true,
},
]
function matchRoute(pathname: string, routePattern: string): boolean {
// Convert route pattern dengan [param] syntax ke regex
const regexPattern = routePattern
.replace(/\\[([^\\]]+)\\]/g, '([^/]+)')
.replace(/\\*/g, '.*')
const regex = new RegExp(`^${regexPattern}$`)
return regex.test(pathname)
}
function findMatchingRoute(pathname: string, routes: ProtectedRoute[]): ProtectedRoute | null {
return routes.find(route => matchRoute(pathname, route.path)) || null
}
export async function middleware(req: NextRequest) {
const res = NextResponse.next()
const supabase = createMiddlewareClient({ req, res })
try {
const { data: { session }, error } = await supabase.auth.getSession()
if (error) {
console.error('Auth error:', error)
return NextResponse.redirect(new URL('/login', req.url))
}
const pathname = req.nextUrl.pathname
const method = req.method
// Check API routes
if (pathname.startsWith('/api/')) {
const matchedRoute = findMatchingRoute(pathname, API_ROUTE_PERMISSIONS)
if (matchedRoute) {
// Allow public routes
if (matchedRoute.public) {
return res
}
// Require authentication
if (!session) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
)
}
// Check method permissions
if (matchedRoute.methods && !matchedRoute.methods.includes(method)) {
return NextResponse.json(
{ error: 'Method not allowed' },
{ status: 405 }
)
}
// Get user profile dan permissions
const { data: profile } = await supabase
.from('profiles')
.select(`
role,
company_id,
laundry_ids,
company:companies(is_active)
`)
.eq('id', session.user.id)
.single()
if (!profile) {
return NextResponse.json(
{ error: 'User profile not found' },
{ status: 403 }
)
}
// Check company requirements
if (matchedRoute.requireCompany) {
if (!profile.company_id || !profile.company?.is_active) {
return NextResponse.json(
{ error: 'Active company required' },
{ status: 403 }
)
}
}
// Check role requirements
if (matchedRoute.roles && !matchedRoute.roles.includes(profile.role as UserRole)) {
return NextResponse.json(
{ error: 'Insufficient role permissions' },
{ status: 403 }
)
}
// Check specific permissions
if (matchedRoute.permissions) {
const userPermissions = getPermissionsByRole(profile.role as UserRole)
const userContext = {
user_id: session.user.id,
company_id: profile.company_id,
laundry_ids: profile.laundry_ids || [],
own_company: true,
own_laundries: true,
}
const hasRequiredPermissions = matchedRoute.permissions.every(permission =>
hasPermission(
userPermissions,
permission.resource,
permission.action,
{ ...userContext, ...permission.context }
)
)
if (!hasRequiredPermissions) {
return NextResponse.json(
{ error: 'Insufficient permissions' },
{ status: 403 }
)
}
}
}
return res
}
// Check page routes
const matchedPageRoute = findMatchingRoute(pathname, PAGE_ROUTE_PERMISSIONS)
if (matchedPageRoute) {
// Require authentication
if (!session) {
const redirectUrl = new URL('/login', req.url)
redirectUrl.searchParams.set('redirectTo', pathname)
return NextResponse.redirect(redirectUrl)
}
// Get user profile
const { data: profile } = await supabase
.from('profiles')
.select(`
role,
company_id,
laundry_ids,
company:companies(is_active)
`)
.eq('id', session.user.id)
.single()
if (!profile) {
return NextResponse.redirect(new URL('/login', req.url))
}
// Check company requirements
if (matchedPageRoute.requireCompany) {
if (!profile.company_id || !profile.company?.is_active) {
return NextResponse.redirect(new URL('/setup-company', req.url))
}
}
// Check role requirements
if (matchedPageRoute.roles && !matchedPageRoute.roles.includes(profile.role as UserRole)) {
return NextResponse.redirect(new URL('/unauthorized', req.url))
}
// Check specific permissions untuk page routes
if (matchedPageRoute.permissions) {
const userPermissions = getPermissionsByRole(profile.role as UserRole)
const userContext = {
user_id: session.user.id,
company_id: profile.company_id,
laundry_ids: profile.laundry_ids || [],
own_company: true,
own_laundries: true,
}
const hasRequiredPermissions = matchedPageRoute.permissions.every(permission =>
hasPermission(
userPermissions,
permission.resource,
permission.action,
{ ...userContext, ...permission.context }
)
)
if (!hasRequiredPermissions) {
return NextResponse.redirect(new URL('/unauthorized', req.url))
}
}
}
return res
} catch (error) {
console.error('Middleware error:', error)
return NextResponse.redirect(new URL('/login', req.url))
}
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|public/).*)',
],
}
Example Usage dalam Components
Mari kita lihat bagaimana menggunakan permission system dalam components:
// Example: Dashboard Laundries Page dengan Permission Guards
'use client'
import { Plus, Edit, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { PermissionGuard, ActionGuard, RoleGuard } from '@/components/auth/withPermission'
import { PermissionResource, PermissionAction, UserRole } from '@/types/auth'
import { usePermissions } from '@/hooks/usePermissions'
export default function LaundriesPage() {
const { hasPermission, hasRole } = usePermissions()
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Laundries</h1>
<p className="text-muted-foreground">Manage your laundry branches</p>
</div>
{/* Only show Add button untuk users yang bisa create laundries */}
<ActionGuard
resource={PermissionResource.LAUNDRIES}
action={PermissionAction.CREATE}
>
<Button>
<Plus className="mr-2 h-4 w-4" />
Add Laundry
</Button>
</ActionGuard>
</div>
{/* Different content berdasarkan role */}
<RoleGuard roles={UserRole.SUPER_ADMIN}>
<div className="bg-blue-50 p-4 rounded-lg">
<h3 className="font-semibold">Super Admin View</h3>
<p>You can see all companies and their laundries</p>
</div>
</RoleGuard>
<RoleGuard roles={UserRole.COMPANY_OWNER}>
<div className="bg-green-50 p-4 rounded-lg">
<h3 className="font-semibold">Company Owner View</h3>
<p>You can manage all laundries in your company</p>
</div>
</RoleGuard>
<RoleGuard roles={UserRole.LAUNDRY_MANAGER}>
<div className="bg-yellow-50 p-4 rounded-lg">
<h3 className="font-semibold">Laundry Manager View</h3>
<p>You can manage laundries you're assigned to</p>
</div>
</RoleGuard>
{/* Table actions berdasarkan permissions */}
<div className="space-y-4">
{/* Mock laundry data */}
{[1, 2, 3].map((id) => (
<div key={id} className="flex items-center justify-between p-4 border rounded-lg">
<div>
<h3 className="font-semibold">Laundry Branch {id}</h3>
<p className="text-sm text-muted-foreground">Jakarta Pusat</p>
</div>
<div className="flex items-center gap-2">
{/* Edit button - only for users yang bisa update */}
<ActionGuard
resource={PermissionResource.LAUNDRIES}
action={PermissionAction.UPDATE}
>
<Button variant="ghost" size="icon">
<Edit className="h-4 w-4" />
</Button>
</ActionGuard>
{/* Delete button - only for users yang bisa delete */}
<ActionGuard
resource={PermissionResource.LAUNDRIES}
action={PermissionAction.DELETE}
>
<Button variant="ghost" size="icon">
<Trash2 className="h-4 w-4" />
</Button>
</ActionGuard>
</div>
</div>
))}
</div>
{/* Analytics section - only for users dengan analytics permission */}
<PermissionGuard
resource={PermissionResource.ANALYTICS}
action={PermissionAction.READ}
>
<div className="mt-8">
<h2 className="text-xl font-semibold mb-4">Analytics</h2>
<div className="grid grid-cols-3 gap-4">
<div className="p-4 bg-card rounded-lg border">
<h3 className="font-semibold">Total Laundries</h3>
<p className="text-2xl font-bold">12</p>
</div>
<div className="p-4 bg-card rounded-lg border">
<h3 className="font-semibold">Active Orders</h3>
<p className="text-2xl font-bold">248</p>
</div>
<div className="p-4 bg-card rounded-lg border">
<h3 className="font-semibold">Revenue Today</h3>
<p className="text-2xl font-bold">Rp 2.5M</p>
</div>
</div>
</div>
</PermissionGuard>
</div>
)
}
Setup PermissionProvider di App Layout
Jangan lupa untuk wrap aplikasi dengan PermissionProvider:
// app/layout.tsx (Updated)
import { AuthProvider } from '@/hooks/useAuth'
import { PermissionProvider } from '@/hooks/usePermissions'
import './globals.css'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<AuthProvider>
<PermissionProvider>
{children}
</PermissionProvider>
</AuthProvider>
</body>
</html>
)
}
Database Migration untuk Enhanced User Roles
Update database schema untuk support new role system:
-- Update profiles table untuk support new roles
ALTER TABLE profiles
ALTER COLUMN role TYPE TEXT;
-- Update constraint untuk include new roles
ALTER TABLE profiles
DROP CONSTRAINT IF EXISTS profiles_role_check;
ALTER TABLE profiles
ADD CONSTRAINT profiles_role_check
CHECK (role IN ('super_admin', 'company_owner', 'laundry_manager', 'cashier', 'customer'));
-- Add laundry_ids column untuk managers yang assigned ke multiple laundries
ALTER TABLE profiles
ADD COLUMN laundry_ids UUID[] DEFAULT '{}';
-- Create index untuk laundry_ids
CREATE INDEX idx_profiles_laundry_ids ON profiles USING GIN(laundry_ids);
-- Function untuk check if user has access to specific laundry
CREATE OR REPLACE FUNCTION user_has_laundry_access(user_id UUID, laundry_id UUID)
RETURNS BOOLEAN AS $$
DECLARE
user_profile RECORD;
BEGIN
SELECT role, company_id, laundry_ids
INTO user_profile
FROM profiles
WHERE id = user_id;
-- Super admin has access to all
IF user_profile.role = 'super_admin' THEN
RETURN TRUE;
END IF;
-- Company owner has access to all company laundries
IF user_profile.role = 'company_owner' THEN
RETURN EXISTS (
SELECT 1 FROM laundries
WHERE id = laundry_id AND company_id = user_profile.company_id
);
END IF;
-- Manager/Cashier has access to assigned laundries
IF user_profile.role IN ('laundry_manager', 'cashier') THEN
RETURN laundry_id = ANY(user_profile.laundry_ids);
END IF;
RETURN FALSE;
END;
$$ LANGUAGE plpgsql;
Integrasi Stripe Payment Gateway untuk SaaS Billing
Alright teman-teman! Sekarang kita masuk ke bagian yang sangat crucial untuk SaaS business - payment integration dengan Stripe. Kita bakal build comprehensive billing system yang handle subscription billing, one-time payments, automated invoicing, dan dunning management. Let's make money moves! 💰
Setup Stripe Account dan API Keys
Pertama-tama, kita perlu setup Stripe account dan get API keys yang diperlukan:
Kunjungi stripe.com dan create account. Setelah itu:
Get API Keys:
- Masuk ke Stripe Dashboard
- Navigasi ke Developers > API Keys
- Copy Publishable Key dan Secret Key (gunakan test keys dulu untuk development)
- Copy juga Webhook Signing Secret (kita bakal setup webhook nanti)
Setup Products dan Prices:
- Masuk ke Products di Stripe Dashboard
- Create products untuk different subscription tiers (Starter, Professional, Enterprise)
- Setup recurring prices (monthly/yearly) untuk setiap product
- Copy Price IDs untuk each subscription tier
Install Dependencies dan Setup Environment
Install Stripe dependencies yang kita butuhin:
npm install stripe @stripe/stripe-js
npm install --save-dev @types/stripe-event-types
Update file .env.local dengan Stripe credentials:
# Stripe Configuration
STRIPE_SECRET_KEY=sk_test_your_secret_key_here
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key_here
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
# Stripe Price IDs untuk subscription plans
STRIPE_STARTER_PRICE_ID=price_starter_monthly
STRIPE_PROFESSIONAL_PRICE_ID=price_professional_monthly
STRIPE_ENTERPRISE_PRICE_ID=price_enterprise_monthly
Konfigurasi Stripe Client
Mari kita setup Stripe client untuk server-side dan client-side operations:
// lib/stripe.ts
import Stripe from 'stripe'
import { loadStripe } from '@stripe/stripe-js'
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error('STRIPE_SECRET_KEY is not set')
}
if (!process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) {
throw new Error('NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is not set')
}
// Server-side Stripe instance
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2023-10-16',
typescript: true,
})
// Client-side Stripe instance
let stripePromise: Promise<Stripe | null>
export const getStripe = () => {
if (!stripePromise) {
stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!)
}
return stripePromise
}
// Subscription plan configuration
export const SUBSCRIPTION_PLANS = {
starter: {
name: 'Starter',
description: 'Perfect for small laundry business',
price: 99000,
priceId: process.env.STRIPE_STARTER_PRICE_ID!,
features: [
'Order Management',
'Customer Database',
'Basic Reports',
'Email Support'
],
limits: {
max_laundries: 1,
max_employees: 5,
max_orders_per_month: 500,
}
},
professional: {
name: 'Professional',
description: 'Great for growing business',
price: 199000,
priceId: process.env.STRIPE_PROFESSIONAL_PRICE_ID!,
features: [
'All Starter Features',
'Multi-branch Management',
'Advanced Analytics',
'WhatsApp Integration',
'Priority Support'
],
limits: {
max_laundries: 5,
max_employees: 25,
max_orders_per_month: 2000,
}
},
enterprise: {
name: 'Enterprise',
description: 'For large laundry chains',
price: 399000,
priceId: process.env.STRIPE_ENTERPRISE_PRICE_ID!,
features: [
'All Professional Features',
'Custom Branding',
'API Access',
'Dedicated Support',
'Custom Integrations'
],
limits: {
max_laundries: -1, // Unlimited
max_employees: -1,
max_orders_per_month: -1,
}
}
} as const
export type SubscriptionPlan = keyof typeof SUBSCRIPTION_PLANS
// Helper functions
export function formatPrice(amount: number, currency: string = 'IDR') {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: currency,
}).format(amount)
}
export function getPlanByPriceId(priceId: string): SubscriptionPlan | null {
for (const [key, plan] of Object.entries(SUBSCRIPTION_PLANS)) {
if (plan.priceId === priceId) {
return key as SubscriptionPlan
}
}
return null
}
Stripe Checkout Implementation
Sekarang mari kita implement Stripe Checkout untuk subscription signup:
// app/api/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { stripe, SUBSCRIPTION_PLANS } from '@/lib/stripe'
import { createRouteHandlerSupabaseClient } from '@/lib/supabase/server'
import { cookies } from 'next/headers'
export async function POST(req: NextRequest) {
try {
const { planId, customerId } = await req.json()
if (!planId || !SUBSCRIPTION_PLANS[planId as keyof typeof SUBSCRIPTION_PLANS]) {
return NextResponse.json(
{ error: 'Invalid plan selected' },
{ status: 400 }
)
}
const supabase = createRouteHandlerSupabaseClient({ cookies })
const { data: { session } } = await supabase.auth.getSession()
if (!session) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
)
}
// Get user profile dan company
const { data: profile } = await supabase
.from('profiles')
.select(`
*,
company:companies(*)
`)
.eq('id', session.user.id)
.single()
if (!profile?.company) {
return NextResponse.json(
{ error: 'Company profile required' },
{ status: 400 }
)
}
const plan = SUBSCRIPTION_PLANS[planId as keyof typeof SUBSCRIPTION_PLANS]
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '<http://localhost:3000>'
// Create atau retrieve Stripe customer
let customer
if (customerId) {
customer = await stripe.customers.retrieve(customerId)
} else {
customer = await stripe.customers.create({
email: profile.email,
name: profile.full_name,
metadata: {
user_id: session.user.id,
company_id: profile.company.id,
},
})
// Update company dengan Stripe customer ID
await supabase
.from('companies')
.update({ stripe_customer_id: customer.id })
.eq('id', profile.company.id)
}
// Create Checkout Session
const checkoutSession = await stripe.checkout.sessions.create({
customer: customer.id,
mode: 'subscription',
payment_method_types: ['card'],
line_items: [
{
price: plan.priceId,
quantity: 1,
},
],
success_url: `${baseUrl}/dashboard/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${baseUrl}/dashboard/billing/cancel`,
metadata: {
user_id: session.user.id,
company_id: profile.company.id,
plan_id: planId,
},
subscription_data: {
metadata: {
user_id: session.user.id,
company_id: profile.company.id,
plan_id: planId,
},
},
allow_promotion_codes: true,
billing_address_collection: 'required',
customer_update: {
address: 'auto',
name: 'auto',
},
})
return NextResponse.json({
sessionId: checkoutSession.id,
url: checkoutSession.url
})
} catch (error) {
console.error('Checkout error:', error)
return NextResponse.json(
{ error: 'Failed to create checkout session' },
{ status: 500 }
)
}
}
Client-side Checkout Component
Mari kita buat component untuk handle checkout di frontend:
// components/billing/CheckoutButton.tsx
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Loader2 } from 'lucide-react'
import { getStripe } from '@/lib/stripe'
import { toast } from 'sonner'
interface CheckoutButtonProps {
planId: string
customerId?: string
className?: string
children: React.ReactNode
}
export function CheckoutButton({
planId,
customerId,
className,
children
}: CheckoutButtonProps) {
const [loading, setLoading] = useState(false)
const handleCheckout = async () => {
setLoading(true)
try {
// Create checkout session
const response = await fetch('/api/checkout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
planId,
customerId,
}),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to create checkout session')
}
// Redirect to Stripe Checkout
const stripe = await getStripe()
if (!stripe) {
throw new Error('Stripe failed to load')
}
const { error } = await stripe.redirectToCheckout({
sessionId: data.sessionId,
})
if (error) {
throw error
}
} catch (error: any) {
console.error('Checkout error:', error)
toast.error(error.message || 'Something went wrong')
} finally {
setLoading(false)
}
}
return (
<Button
onClick={handleCheckout}
disabled={loading}
className={className}
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : (
children
)}
</Button>
)
}
Webhook Endpoint untuk Event Handling
Ini bagian yang sangat penting - webhook untuk handle Stripe events:
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { headers } from 'next/headers'
import { stripe } from '@/lib/stripe'
import { createRouteHandlerSupabaseClient } from '@/lib/supabase/server'
import { cookies } from 'next/headers'
import Stripe from 'stripe'
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!
export async function POST(req: NextRequest) {
const body = await req.text()
const signature = headers().get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
} catch (err: any) {
console.error('Webhook signature verification failed:', err.message)
return NextResponse.json(
{ error: 'Webhook signature verification failed' },
{ status: 400 }
)
}
const supabase = createRouteHandlerSupabaseClient({ cookies })
try {
switch (event.type) {
case 'customer.subscription.created':
await handleSubscriptionCreated(event.data.object as Stripe.Subscription, supabase)
break
case 'customer.subscription.updated':
await handleSubscriptionUpdated(event.data.object as Stripe.Subscription, supabase)
break
case 'customer.subscription.deleted':
await handleSubscriptionDeleted(event.data.object as Stripe.Subscription, supabase)
break
case 'invoice.payment_succeeded':
await handleInvoicePaymentSucceeded(event.data.object as Stripe.Invoice, supabase)
break
case 'invoice.payment_failed':
await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice, supabase)
break
case 'customer.subscription.trial_will_end':
await handleTrialWillEnd(event.data.object as Stripe.Subscription, supabase)
break
case 'checkout.session.completed':
await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session, supabase)
break
default:
console.log(`Unhandled event type: ${event.type}`)
}
return NextResponse.json({ received: true })
} catch (error) {
console.error('Webhook handler error:', error)
return NextResponse.json(
{ error: 'Webhook handler failed' },
{ status: 500 }
)
}
}
async function handleSubscriptionCreated(subscription: Stripe.Subscription, supabase: any) {
const companyId = subscription.metadata.company_id
const planId = subscription.metadata.plan_id
if (!companyId) {
console.error('No company_id in subscription metadata')
return
}
// Update atau create subscription record
const { error } = await supabase
.from('subscriptions')
.upsert({
company_id: companyId,
stripe_subscription_id: subscription.id,
stripe_customer_id: subscription.customer,
plan_id: planId,
status: subscription.status,
current_period_start: new Date(subscription.current_period_start * 1000).toISOString(),
current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
trial_start: subscription.trial_start ? new Date(subscription.trial_start * 1000).toISOString() : null,
trial_end: subscription.trial_end ? new Date(subscription.trial_end * 1000).toISOString() : null,
})
if (error) {
console.error('Error creating subscription:', error)
throw error
}
console.log(`Subscription created for company ${companyId}`)
}
async function handleSubscriptionUpdated(subscription: Stripe.Subscription, supabase: any) {
const { error } = await supabase
.from('subscriptions')
.update({
status: subscription.status,
current_period_start: new Date(subscription.current_period_start * 1000).toISOString(),
current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
trial_start: subscription.trial_start ? new Date(subscription.trial_start * 1000).toISOString() : null,
trial_end: subscription.trial_end ? new Date(subscription.trial_end * 1000).toISOString() : null,
canceled_at: subscription.canceled_at ? new Date(subscription.canceled_at * 1000).toISOString() : null,
})
.eq('stripe_subscription_id', subscription.id)
if (error) {
console.error('Error updating subscription:', error)
throw error
}
console.log(`Subscription updated: ${subscription.id}`)
}
async function handleSubscriptionDeleted(subscription: Stripe.Subscription, supabase: any) {
const { error } = await supabase
.from('subscriptions')
.update({
status: 'canceled',
canceled_at: new Date().toISOString(),
})
.eq('stripe_subscription_id', subscription.id)
if (error) {
console.error('Error deleting subscription:', error)
throw error
}
console.log(`Subscription canceled: ${subscription.id}`)
}
async function handleInvoicePaymentSucceeded(invoice: Stripe.Invoice, supabase: any) {
if (!invoice.subscription) return
// Record successful payment
const { error } = await supabase
.from('transactions')
.insert({
company_id: invoice.metadata?.company_id,
stripe_invoice_id: invoice.id,
stripe_payment_intent_id: invoice.payment_intent,
amount: invoice.amount_paid / 100, // Convert dari cents
currency: invoice.currency.toUpperCase(),
transaction_type: 'subscription_payment',
status: 'succeeded',
metadata: {
invoice_number: invoice.number,
subscription_id: invoice.subscription,
},
})
if (error) {
console.error('Error recording payment:', error)
throw error
}
// Update subscription status to active jika sebelumnya past_due
await supabase
.from('subscriptions')
.update({ status: 'active' })
.eq('stripe_subscription_id', invoice.subscription)
console.log(`Payment succeeded for invoice: ${invoice.id}`)
}
async function handleInvoicePaymentFailed(invoice: Stripe.Invoice, supabase: any) {
if (!invoice.subscription) return
// Record failed payment
const { error } = await supabase
.from('transactions')
.insert({
company_id: invoice.metadata?.company_id,
stripe_invoice_id: invoice.id,
amount: invoice.amount_due / 100,
currency: invoice.currency.toUpperCase(),
transaction_type: 'subscription_payment',
status: 'failed',
metadata: {
invoice_number: invoice.number,
subscription_id: invoice.subscription,
failure_reason: 'payment_failed',
},
})
if (error) {
console.error('Error recording failed payment:', error)
}
// Update subscription status
await supabase
.from('subscriptions')
.update({ status: 'past_due' })
.eq('stripe_subscription_id', invoice.subscription)
// TODO: Send notification email untuk failed payment
// await sendPaymentFailedNotification(invoice)
console.log(`Payment failed for invoice: ${invoice.id}`)
}
async function handleTrialWillEnd(subscription: Stripe.Subscription, supabase: any) {
// TODO: Send trial ending notification
console.log(`Trial will end for subscription: ${subscription.id}`)
}
async function handleCheckoutCompleted(session: Stripe.Checkout.Session, supabase: any) {
const companyId = session.metadata?.company_id
if (session.mode === 'subscription' && companyId) {
// Subscription akan di-handle oleh subscription.created event
console.log(`Checkout completed for subscription: ${session.subscription}`)
} else if (session.mode === 'payment' && companyId) {
// Handle one-time payment
const { error } = await supabase
.from('transactions')
.insert({
company_id: companyId,
stripe_session_id: session.id,
stripe_payment_intent_id: session.payment_intent,
amount: session.amount_total! / 100,
currency: session.currency!.toUpperCase(),
transaction_type: 'one_time_payment',
status: 'succeeded',
metadata: {
session_id: session.id,
},
})
if (error) {
console.error('Error recording one-time payment:', error)
}
console.log(`One-time payment completed: ${session.id}`)
}
}
Customer Portal Implementation
Mari kita implement customer portal untuk self-service billing management:
// app/api/billing/customer-portal/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { stripe } from '@/lib/stripe'
import { createRouteHandlerSupabaseClient } from '@/lib/supabase/server'
import { cookies } from 'next/headers'
export async function POST(req: NextRequest) {
try {
const supabase = createRouteHandlerSupabaseClient({ cookies })
const { data: { session } } = await supabase.auth.getSession()
if (!session) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
)
}
// Get company dengan Stripe customer ID
const { data: profile } = await supabase
.from('profiles')
.select(`
*,
company:companies(*)
`)
.eq('id', session.user.id)
.single()
if (!profile?.company?.stripe_customer_id) {
return NextResponse.json(
{ error: 'No billing account found' },
{ status: 400 }
)
}
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '<http://localhost:3000>'
// Create customer portal session
const portalSession = await stripe.billingPortal.sessions.create({
customer: profile.company.stripe_customer_id,
return_url: `${baseUrl}/dashboard/billing`,
})
return NextResponse.json({ url: portalSession.url })
} catch (error) {
console.error('Customer portal error:', error)
return NextResponse.json(
{ error: 'Failed to create portal session' },
{ status: 500 }
)
}
}
Billing Dashboard Component
Mari kita buat comprehensive billing dashboard:
// components/billing/BillingDashboard.tsx
'use client'
import { useState, useEffect } from 'react'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import {
CreditCard,
Download,
ExternalLink,
Calendar,
DollarSign,
AlertTriangle
} from 'lucide-react'
import { CheckoutButton } from './CheckoutButton'
import { formatPrice } from '@/lib/stripe'
import { useAuth } from '@/hooks/useAuth'
interface Subscription {
id: string
plan_id: string
status: string
current_period_start: string
current_period_end: string
trial_end?: string
canceled_at?: string
}
interface Invoice {
id: string
amount_paid: number
currency: string
status: string
created: string
invoice_pdf?: string
hosted_invoice_url?: string
}
export function BillingDashboard() {
const [subscription, setSubscription] = useState<Subscription | null>(null)
const [invoices, setInvoices] = useState<Invoice[]>([])
const [loading, setLoading] = useState(true)
const { profile } = useAuth()
useEffect(() => {
fetchBillingData()
}, [])
const fetchBillingData = async () => {
try {
// Fetch subscription dan invoices
const [subRes, invRes] = await Promise.all([
fetch('/api/billing/subscription'),
fetch('/api/billing/invoices'),
])
if (subRes.ok) {
const subData = await subRes.json()
setSubscription(subData)
}
if (invRes.ok) {
const invData = await invRes.json()
setInvoices(invData)
}
} catch (error) {
console.error('Error fetching billing data:', error)
} finally {
setLoading(false)
}
}
const handleCustomerPortal = async () => {
try {
const response = await fetch('/api/billing/customer-portal', {
method: 'POST',
})
const data = await response.json()
if (response.ok) {
window.open(data.url, '_blank')
} else {
throw new Error(data.error)
}
} catch (error) {
console.error('Error opening customer portal:', error)
}
}
const getStatusColor = (status: string) => {
switch (status) {
case 'active': return 'bg-green-100 text-green-800'
case 'trialing': return 'bg-blue-100 text-blue-800'
case 'past_due': return 'bg-yellow-100 text-yellow-800'
case 'canceled': return 'bg-red-100 text-red-800'
default: return 'bg-gray-100 text-gray-800'
}
}
if (loading) {
return (
<div className="space-y-6">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-4 bg-gray-200 rounded w-1/4"></div>
<div className="h-8 bg-gray-200 rounded w-1/2"></div>
</div>
</CardContent>
</Card>
))}
</div>
)
}
return (
<div className="space-y-6">
{/* Current Subscription */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CreditCard className="h-5 w-5" />
Current Subscription
</CardTitle>
</CardHeader>
<CardContent>
{subscription ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold capitalize">
{subscription.plan_id} Plan
</h3>
<p className="text-sm text-muted-foreground">
{subscription.trial_end ? 'Trial period' : 'Active subscription'}
</p>
</div>
<Badge className={getStatusColor(subscription.status)}>
{subscription.status}
</Badge>
</div>
{subscription.status === 'past_due' && (
<div className="flex items-center gap-2 p-3 bg-yellow-50 rounded-lg border border-yellow-200">
<AlertTriangle className="h-4 w-4 text-yellow-600" />
<p className="text-sm text-yellow-800">
Your payment is past due. Please update your payment method.
</p>
</div>
)}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Current period</p>
<p className="font-medium">
{new Date(subscription.current_period_start).toLocaleDateString()} - {' '}
{new Date(subscription.current_period_end).toLocaleDateString()}
</p>
</div>
{subscription.trial_end && (
<div>
<p className="text-muted-foreground">Trial ends</p>
<p className="font-medium">
{new Date(subscription.trial_end).toLocaleDateString()}
</p>
</div>
)}
</div>
<Separator />
<div className="flex gap-3">
<Button onClick={handleCustomerPortal} variant="outline">
<ExternalLink className="mr-2 h-4 w-4" />
Manage Subscription
</Button>
{subscription.status === 'canceled' && (
<CheckoutButton planId={subscription.plan_id}>
Reactivate Subscription
</CheckoutButton>
)}
</div>
</div>
) : (
<div className="text-center py-8">
<h3 className="text-lg font-semibold mb-2">No Active Subscription</h3>
<p className="text-muted-foreground mb-4">
Choose a plan to get started with LaundryPro
</p>
<Button asChild>
<a href="/pricing">View Plans</a>
</Button>
</div>
)}
</CardContent>
</Card>
{/* Billing History */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DollarSign className="h-5 w-5" />
Billing History
</CardTitle>
</CardHeader>
<CardContent>
{invoices.length > 0 ? (
<div className="space-y-4">
{invoices.map((invoice) => (
<div key={invoice.id} className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center gap-4">
<div className="flex-1">
<p className="font-medium">
{formatPrice(invoice.amount_paid, invoice.currency)}
</p>
<p className="text-sm text-muted-foreground">
{new Date(invoice.created).toLocaleDateString()}
</p>
</div>
<Badge
variant={invoice.status === 'paid' ? 'default' : 'destructive'}
>
{invoice.status}
</Badge>
</div>
<div className="flex items-center gap-2">
{invoice.invoice_pdf && (
<Button variant="ghost" size="icon" asChild>
<a href={invoice.invoice_pdf} target="_blank" rel="noopener noreferrer">
<Download className="h-4 w-4" />
</a>
</Button>
)}
{invoice.hosted_invoice_url && (
<Button variant="ghost" size="icon" asChild>
<a href={invoice.hosted_invoice_url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
)}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8">
<Calendar className="mx-auto h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">No Billing History</h3>
<p className="text-muted-foreground">
Your billing history will appear here once you have active subscriptions.
</p>
</div>
)}
</CardContent>
</Card>
{/* Usage Overview (if applicable) */}
{subscription && (
<Card>
<CardHeader>
<CardTitle>Usage Overview</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4">
<div className="text-center">
<p className="text-2xl font-bold">5</p>
<p className="text-sm text-muted-foreground">Active Laundries</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold">248</p>
<p className="text-sm text-muted-foreground">Orders This Month</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold">12</p>
<p className="text-sm text-muted-foreground">Team Members</p>
</div>
</div>
</CardContent>
</Card>
)}
</div>
)
}
Automated Invoice Generation
Mari kita implement automated invoice generation untuk custom billing:
// lib/billing/invoice-generator.ts
import { stripe } from '@/lib/stripe'
import { createRouteHandlerSupabaseClient } from '@/lib/supabase/server'
import { cookies } from 'next/headers'
interface InvoiceItem {
description: string
amount: number
quantity?: number
}
export async function generateInvoice(
companyId: string,
items: InvoiceItem[],
dueDate?: Date
) {
const supabase = createRouteHandlerSupabaseClient({ cookies })
try {
// Get company dan customer information
const { data: company } = await supabase
.from('companies')
.select('*')
.eq('id', companyId)
.single()
if (!company?.stripe_customer_id) {
throw new Error('No Stripe customer found for company')
}
// Create invoice items
const invoiceItems = []
for (const item of items) {
const invoiceItem = await stripe.invoiceItems.create({
customer: company.stripe_customer_id,
amount: Math.round(item.amount * 100), // Convert to cents
currency: 'idr',
description: item.description,
quantity: item.quantity || 1,
})
invoiceItems.push(invoiceItem)
}
// Create draft invoice
const invoice = await stripe.invoices.create({
customer: company.stripe_customer_id,
collection_method: 'send_invoice',
days_until_due: dueDate ? Math.ceil((dueDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24)) : 30,
metadata: {
company_id: companyId,
type: 'custom_invoice',
},
})
// Finalize dan send invoice
await stripe.invoices.finalizeInvoice(invoice.id)
await stripe.invoices.sendInvoice(invoice.id)
// Record dalam database
await supabase
.from('transactions')
.insert({
company_id: companyId,
stripe_invoice_id: invoice.id,
amount: invoice.amount_due / 100,
currency: invoice.currency.toUpperCase(),
transaction_type: 'custom_invoice',
status: 'pending',
metadata: {
invoice_number: invoice.number,
due_date: invoice.due_date ? new Date(invoice.due_date * 1000).toISOString() : null,
},
})
return {
success: true,
invoice_id: invoice.id,
invoice_url: invoice.hosted_invoice_url,
}
} catch (error) {
console.error('Error generating invoice:', error)
throw error
}
}
// Usage example:
// await generateInvoice('company-id', [
// { description: 'Setup Fee', amount: 500000 },
// { description: 'Training Session', amount: 2000000, quantity: 2 }
// ])
Dunning Management untuk Failed Payments
Mari kita implement automated dunning management:
// lib/billing/dunning-management.ts
import { stripe } from '@/lib/stripe'
import { createRouteHandlerSupabaseClient } from '@/lib/supabase/server'
import { cookies } from 'next/headers'
export async function handleFailedPayment(subscriptionId: string) {
const supabase = createRouteHandlerSupabaseClient({ cookies })
try {
// Get subscription details
const subscription = await stripe.subscriptions.retrieve(subscriptionId)
const companyId = subscription.metadata.company_id
if (!companyId) {
console.error('No company_id in subscription metadata')
return
}
// Get company details
const { data: company } = await supabase
.from('companies')
.select(`
*,
profiles!company_id(email, full_name)
`)
.eq('id', companyId)
.single()
if (!company) {
console.error('Company not found:', companyId)
return
}
// Update subscription status
await supabase
.from('subscriptions')
.update({
status: 'past_due',
dunning_count: supabase.rpc('increment_dunning_count', { subscription_id: subscriptionId })
})
.eq('stripe_subscription_id', subscriptionId)
// Get current dunning count
const { data: subData } = await supabase
.from('subscriptions')
.select('dunning_count')
.eq('stripe_subscription_id', subscriptionId)
.single()
const dunningCount = subData?.dunning_count || 0
// Send appropriate notification berdasarkan dunning count
if (dunningCount === 1) {
await sendPaymentFailedNotification(company, subscription)
} else if (dunningCount === 3) {
await sendFinalWarningNotification(company, subscription)
} else if (dunningCount >= 5) {
await handleFinalFailure(subscription, companyId)
}
} catch (error) {
console.error('Error handling failed payment:', error)
}
}
async function sendPaymentFailedNotification(company: any, subscription: any) {
// TODO: Implement email notification
console.log(`Sending payment failed notification to ${company.profiles[0]?.email}`)
// Example: Send email via your email service
// await emailService.send({
// to: company.profiles[0]?.email,
// template: 'payment-failed',
// data: {
// company_name: company.name,
// amount: subscription.latest_invoice.amount_due / 100,
// retry_date: new Date(subscription.latest_invoice.next_payment_attempt * 1000),
// }
// })
}
async function sendFinalWarningNotification(company: any, subscription: any) {
console.log(`Sending final warning to ${company.profiles[0]?.email}`)
// TODO: Implement final warning email
}
async function handleFinalFailure(subscription: any, companyId: string) {
const supabase = createRouteHandlerSupabaseClient({ cookies })
try {
// Cancel subscription
await stripe.subscriptions.cancel(subscription.id)
// Update database
await supabase
.from('subscriptions')
.update({
status: 'canceled',
canceled_at: new Date().toISOString(),
})
.eq('stripe_subscription_id', subscription.id)
// Deactivate company (optional - depends on business logic)
await supabase
.from('companies')
.update({ is_active: false })
.eq('id', companyId)
console.log(`Subscription canceled due to repeated payment failures: ${subscription.id}`)
} catch (error) {
console.error('Error handling final failure:', error)
}
}
// Function untuk retry failed payment
export async function retryFailedPayment(subscriptionId: string) {
try {
const subscription = await stripe.subscriptions.retrieve(subscriptionId)
if (subscription.latest_invoice) {
const invoice = await stripe.invoices.retrieve(subscription.latest_invoice as string)
if (invoice.status === 'open') {
// Retry payment
await stripe.invoices.pay(invoice.id)
return { success: true }
}
}
return { success: false, error: 'No open invoice to retry' }
} catch (error: any) {
console.error('Error retrying payment:', error)
return { success: false, error: error.message }
}
}
Success dan Cancel Pages
Mari kita buat pages untuk handle success dan cancel dari Stripe Checkout:
// app/(dashboard)/billing/success/page.tsx
'use client'
import { useEffect, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { CheckCircle, ArrowRight } from 'lucide-react'
import Link from 'next/link'
export default function BillingSuccessPage() {
const searchParams = useSearchParams()
const sessionId = searchParams.get('session_id')
const [loading, setLoading] = useState(true)
const [sessionData, setSessionData] = useState<any>(null)
useEffect(() => {
if (sessionId) {
verifySession()
}
}, [sessionId])
const verifySession = async () => {
try {
const response = await fetch(`/api/checkout/verify?session_id=${sessionId}`)
const data = await response.json()
if (response.ok) {
setSessionData(data)
}
} catch (error) {
console.error('Error verifying session:', error)
} finally {
setLoading(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
)
}
return (
<div className="max-w-md mx-auto mt-16">
<Card>
<CardHeader className="text-center">
<div className="mx-auto w-12 h-12 bg-green-100 rounded-full flex items-center justify-center mb-4">
<CheckCircle className="w-6 h-6 text-green-600" />
</div>
<CardTitle className="text-green-900">Payment Successful!</CardTitle>
</CardHeader>
<CardContent className="text-center space-y-4">
<p className="text-muted-foreground">
Thank you for your subscription. Your account has been activated and you can now access all features.
</p>
{sessionData && (
<div className="bg-muted p-4 rounded-lg text-sm">
<p><strong>Plan:</strong> {sessionData.plan_name}</p>
<p><strong>Amount:</strong> {sessionData.amount}</p>
<p><strong>Next billing:</strong> {sessionData.next_billing_date}</p>
</div>
)}
<div className="flex gap-3">
<Button asChild variant="outline" className="flex-1">
<Link href="/dashboard/billing">
View Billing
</Link>
</Button>
<Button asChild className="flex-1">
<Link href="/dashboard">
Go to Dashboard
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
Implementasi Real-time Features dengan Supabase Realtime
Alright teman-teman! Sekarang kita masuk ke bagian yang bakal bikin aplikasi kita terasa hidup - real-time features! Dengan Supabase Realtime, kita bisa provide live updates yang instant, optimistic UI yang smooth, dan push notifications yang keep users engaged. Let's build something amazing! 🚀
Setup Supabase Realtime dan Row Level Security
Pertama-tama, kita perlu enable realtime di Supabase dan setup RLS policies yang support realtime subscriptions:
-- Enable realtime untuk tables yang kita butuhin
ALTER PUBLICATION supabase_realtime ADD TABLE orders;
ALTER PUBLICATION supabase_realtime ADD TABLE notifications;
ALTER PUBLICATION supabase_realtime ADD TABLE transactions;
ALTER PUBLICATION supabase_realtime ADD TABLE customers;
-- Create notifications table untuk system notifications
CREATE TABLE notifications (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
title TEXT NOT NULL,
message TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('info', 'success', 'warning', 'error')),
category TEXT NOT NULL CHECK (category IN ('order', 'payment', 'system', 'marketing')),
data JSONB DEFAULT '{}',
is_read BOOLEAN DEFAULT false,
expires_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes untuk notifications
CREATE INDEX idx_notifications_company_id ON notifications(company_id);
CREATE INDEX idx_notifications_user_id ON notifications(user_id);
CREATE INDEX idx_notifications_is_read ON notifications(is_read);
CREATE INDEX idx_notifications_created_at ON notifications(created_at);
-- RLS policies untuk notifications
ALTER TABLE notifications ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view company notifications" ON notifications FOR SELECT
USING (
company_id IN (SELECT company_id FROM profiles WHERE id = auth.uid())
OR user_id = auth.uid()
);
CREATE POLICY "System can insert notifications" ON notifications FOR INSERT
USING (true); -- System/triggers dapat insert notifications
CREATE POLICY "Users can update own notifications" ON notifications FOR UPDATE
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
-- Function untuk create notification
CREATE OR REPLACE FUNCTION create_notification(
p_company_id UUID,
p_user_id UUID DEFAULT NULL,
p_title TEXT,
p_message TEXT,
p_type TEXT DEFAULT 'info',
p_category TEXT DEFAULT 'system',
p_data JSONB DEFAULT '{}'
)
RETURNS UUID AS $$
DECLARE
notification_id UUID;
BEGIN
INSERT INTO notifications (
company_id, user_id, title, message, type, category, data
) VALUES (
p_company_id, p_user_id, p_title, p_message, p_type, p_category, p_data
) RETURNING id INTO notification_id;
RETURN notification_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Trigger untuk create notification saat order status berubah
CREATE OR REPLACE FUNCTION notify_order_status_change()
RETURNS TRIGGER AS $$
BEGIN
-- Notify jika status berubah
IF OLD.status IS DISTINCT FROM NEW.status THEN
PERFORM create_notification(
NEW.company_id,
NULL, -- Broadcast ke semua company users
'Order Status Updated',
format('Order %s is now %s', NEW.order_number, NEW.status),
'info',
'order',
jsonb_build_object(
'order_id', NEW.id,
'order_number', NEW.order_number,
'old_status', OLD.status,
'new_status', NEW.status
)
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER order_status_notification
AFTER UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION notify_order_status_change();
Enhanced Supabase Client untuk Realtime
Mari kita enhance Supabase client dengan realtime configuration:
// lib/supabase/realtime.ts
import { createClient } from '@/lib/supabase/client'
import { RealtimeChannel, RealtimePostgresChangesPayload } from '@supabase/supabase-js'
export type RealtimeEvent = 'INSERT' | 'UPDATE' | 'DELETE'
export interface RealtimeSubscriptionConfig {
table: string
event?: RealtimeEvent | '*'
schema?: string
filter?: string
}
export class RealtimeManager {
private client = createClient()
private channels: Map<string, RealtimeChannel> = new Map()
private subscribers: Map<string, Set<Function>> = new Map()
subscribe<T = any>(
config: RealtimeSubscriptionConfig,
callback: (payload: RealtimePostgresChangesPayload<T>) => void
): () => void {
const channelName = this.getChannelName(config)
// Get or create channel
let channel = this.channels.get(channelName)
if (!channel) {
channel = this.client.channel(channelName)
this.channels.set(channelName, channel)
}
// Setup subscription
const subscription = channel.on(
'postgres_changes',
{
event: config.event || '*',
schema: config.schema || 'public',
table: config.table,
filter: config.filter,
},
callback
)
// Track subscribers
if (!this.subscribers.has(channelName)) {
this.subscribers.set(channelName, new Set())
}
this.subscribers.get(channelName)!.add(callback)
// Subscribe to channel if this is the first subscriber
if (this.subscribers.get(channelName)!.size === 1) {
channel.subscribe((status) => {
console.log(`Realtime ${channelName} status:`, status)
})
}
// Return unsubscribe function
return () => {
this.unsubscribe(channelName, callback)
}
}
private unsubscribe(channelName: string, callback: Function) {
const subscribers = this.subscribers.get(channelName)
if (subscribers) {
subscribers.delete(callback)
// If no more subscribers, unsubscribe from channel
if (subscribers.size === 0) {
const channel = this.channels.get(channelName)
if (channel) {
this.client.removeChannel(channel)
this.channels.delete(channelName)
this.subscribers.delete(channelName)
}
}
}
}
private getChannelName(config: RealtimeSubscriptionConfig): string {
return `${config.table}_${config.event || 'all'}_${config.filter || 'all'}`
}
// Cleanup all subscriptions
cleanup() {
for (const [channelName, channel] of this.channels) {
this.client.removeChannel(channel)
}
this.channels.clear()
this.subscribers.clear()
}
}
// Global instance
export const realtimeManager = new RealtimeManager()
Custom Hook untuk Realtime Orders
Mari kita buat custom hook untuk handle realtime order updates:
// hooks/useRealtimeOrders.ts
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useAuth } from '@/hooks/useAuth'
import { realtimeManager } from '@/lib/supabase/realtime'
import { createClient } from '@/lib/supabase/client'
import { toast } from 'sonner'
export interface Order {
id: string
order_number: string
customer_id: string
laundry_id: string
status: string
total_amount: number
created_at: string
updated_at: string
customer?: {
full_name: string
phone: string
}
laundry?: {
name: string
}
}
interface UseRealtimeOrdersOptions {
laundryId?: string
status?: string
autoRefresh?: boolean
}
export function useRealtimeOrders(options: UseRealtimeOrdersOptions = {}) {
const [orders, setOrders] = useState<Order[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const { profile } = useAuth()
const supabase = createClient()
const unsubscribeRef = useRef<(() => void) | null>(null)
// Optimistic updates state
const [optimisticUpdates, setOptimisticUpdates] = useState<Map<string, Partial<Order>>>(new Map())
const fetchOrders = useCallback(async () => {
if (!profile?.company_id) return
try {
setLoading(true)
setError(null)
let query = supabase
.from('orders')
.select(`
*,
customer:customers(full_name, phone),
laundry:laundries(name)
`)
.eq('company_id', profile.company_id)
.order('created_at', { ascending: false })
if (options.laundryId) {
query = query.eq('laundry_id', options.laundryId)
}
if (options.status) {
query = query.eq('status', options.status)
}
const { data, error } = await query
if (error) throw error
setOrders(data || [])
} catch (err: any) {
setError(err.message)
toast.error('Failed to fetch orders')
} finally {
setLoading(false)
}
}, [profile?.company_id, options.laundryId, options.status, supabase])
// Setup realtime subscription
useEffect(() => {
if (!profile?.company_id) return
// Initial fetch
fetchOrders()
// Setup realtime subscription
const filter = `company_id=eq.${profile.company_id}`
unsubscribeRef.current = realtimeManager.subscribe(
{
table: 'orders',
filter: filter,
},
(payload) => {
console.log('Realtime order update:', payload)
switch (payload.eventType) {
case 'INSERT':
handleOrderInsert(payload.new as Order)
break
case 'UPDATE':
handleOrderUpdate(payload.new as Order, payload.old as Order)
break
case 'DELETE':
handleOrderDelete(payload.old as Order)
break
}
}
)
return () => {
if (unsubscribeRef.current) {
unsubscribeRef.current()
}
}
}, [profile?.company_id, fetchOrders])
const handleOrderInsert = useCallback((newOrder: Order) => {
// Check if order matches current filters
if (options.laundryId && newOrder.laundry_id !== options.laundryId) return
if (options.status && newOrder.status !== options.status) return
setOrders(prev => {
// Check if order already exists (prevent duplicates)
if (prev.some(order => order.id === newOrder.id)) return prev
return [newOrder, ...prev]
})
// Show notification for new orders
if (newOrder.status === 'pending') {
toast.success(`New order received: ${newOrder.order_number}`)
}
}, [options.laundryId, options.status])
const handleOrderUpdate = useCallback((updatedOrder: Order, oldOrder: Order) => {
setOrders(prev => prev.map(order =>
order.id === updatedOrder.id ? updatedOrder : order
))
// Remove optimistic update if it exists
setOptimisticUpdates(prev => {
const newMap = new Map(prev)
newMap.delete(updatedOrder.id)
return newMap
})
// Show notification for status changes
if (oldOrder.status !== updatedOrder.status) {
toast.info(`Order ${updatedOrder.order_number} is now ${updatedOrder.status}`)
}
}, [])
const handleOrderDelete = useCallback((deletedOrder: Order) => {
setOrders(prev => prev.filter(order => order.id !== deletedOrder.id))
toast.info(`Order ${deletedOrder.order_number} has been deleted`)
}, [])
// Optimistic update function
const updateOrderOptimistic = useCallback(async (
orderId: string,
updates: Partial<Order>,
serverUpdate: () => Promise<void>
) => {
// Apply optimistic update
setOptimisticUpdates(prev => new Map(prev).set(orderId, updates))
try {
// Perform server update
await serverUpdate()
// Optimistic update akan di-remove saat realtime update diterima
} catch (error: any) {
// Rollback optimistic update
setOptimisticUpdates(prev => {
const newMap = new Map(prev)
newMap.delete(orderId)
return newMap
})
toast.error(`Failed to update order: ${error.message}`)
throw error
}
}, [])
// Update order status dengan optimistic UI
const updateOrderStatus = useCallback(async (orderId: string, newStatus: string) => {
const updateFunction = async () => {
const { error } = await supabase
.from('orders')
.update({
status: newStatus,
updated_at: new Date().toISOString()
})
.eq('id', orderId)
if (error) throw error
}
await updateOrderOptimistic(
orderId,
{ status: newStatus, updated_at: new Date().toISOString() },
updateFunction
)
}, [supabase, updateOrderOptimistic])
// Get orders dengan optimistic updates applied
const ordersWithOptimistic = orders.map(order => {
const optimisticUpdate = optimisticUpdates.get(order.id)
return optimisticUpdate ? { ...order, ...optimisticUpdate } : order
})
return {
orders: ordersWithOptimistic,
loading,
error,
refetch: fetchOrders,
updateOrderStatus,
optimisticUpdates: optimisticUpdates.size > 0,
}
}
Custom Hook untuk Realtime Dashboard
Mari kita buat hook untuk realtime dashboard metrics:
// hooks/useRealtimeDashboard.ts
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useAuth } from '@/hooks/useAuth'
import { realtimeManager } from '@/lib/supabase/realtime'
import { createClient } from '@/lib/supabase/client'
export interface DashboardMetrics {
totalOrders: number
pendingOrders: number
completedOrders: number
totalRevenue: number
todayOrders: number
todayRevenue: number
activeCustomers: number
averageOrderValue: number
}
export function useRealtimeDashboard() {
const [metrics, setMetrics] = useState<DashboardMetrics>({
totalOrders: 0,
pendingOrders: 0,
completedOrders: 0,
totalRevenue: 0,
todayOrders: 0,
todayRevenue: 0,
activeCustomers: 0,
averageOrderValue: 0,
})
const [loading, setLoading] = useState(true)
const { profile } = useAuth()
const supabase = createClient()
const calculateMetrics = useCallback(async () => {
if (!profile?.company_id) return
try {
setLoading(true)
// Get all metrics in parallel
const [
totalOrdersRes,
pendingOrdersRes,
completedOrdersRes,
revenueRes,
todayOrdersRes,
todayRevenueRes,
activeCustomersRes,
] = await Promise.all([
// Total orders
supabase
.from('orders')
.select('id', { count: 'exact', head: true })
.eq('company_id', profile.company_id),
// Pending orders
supabase
.from('orders')
.select('id', { count: 'exact', head: true })
.eq('company_id', profile.company_id)
.in('status', ['pending', 'confirmed', 'in_progress']),
// Completed orders
supabase
.from('orders')
.select('id', { count: 'exact', head: true })
.eq('company_id', profile.company_id)
.eq('status', 'completed'),
// Total revenue
supabase
.from('orders')
.select('total_amount')
.eq('company_id', profile.company_id)
.eq('status', 'completed'),
// Today's orders
supabase
.from('orders')
.select('id', { count: 'exact', head: true })
.eq('company_id', profile.company_id)
.gte('created_at', new Date().toISOString().split('T')[0]),
// Today's revenue
supabase
.from('orders')
.select('total_amount')
.eq('company_id', profile.company_id)
.eq('status', 'completed')
.gte('created_at', new Date().toISOString().split('T')[0]),
// Active customers (customers with orders in last 30 days)
supabase
.from('orders')
.select('customer_id')
.eq('company_id', profile.company_id)
.gte('created_at', new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString())
])
const totalRevenue = revenueRes.data?.reduce((sum, order) => sum + (order.total_amount || 0), 0) || 0
const todayRevenue = todayRevenueRes.data?.reduce((sum, order) => sum + (order.total_amount || 0), 0) || 0
const totalOrders = totalOrdersRes.count || 0
const uniqueCustomers = new Set(activeCustomersRes.data?.map(order => order.customer_id)).size
setMetrics({
totalOrders,
pendingOrders: pendingOrdersRes.count || 0,
completedOrders: completedOrdersRes.count || 0,
totalRevenue,
todayOrders: todayOrdersRes.count || 0,
todayRevenue,
activeCustomers: uniqueCustomers,
averageOrderValue: totalOrders > 0 ? totalRevenue / totalOrders : 0,
})
} catch (error) {
console.error('Error calculating metrics:', error)
} finally {
setLoading(false)
}
}, [profile?.company_id, supabase])
// Setup realtime subscriptions untuk orders dan transactions
useEffect(() => {
if (!profile?.company_id) return
// Initial calculation
calculateMetrics()
// Subscribe to orders changes
const unsubscribeOrders = realtimeManager.subscribe(
{
table: 'orders',
filter: `company_id=eq.${profile.company_id}`,
},
() => {
// Recalculate metrics saat ada perubahan orders
calculateMetrics()
}
)
// Subscribe to transactions changes
const unsubscribeTransactions = realtimeManager.subscribe(
{
table: 'transactions',
filter: `company_id=eq.${profile.company_id}`,
},
() => {
// Recalculate metrics saat ada perubahan transactions
calculateMetrics()
}
)
return () => {
unsubscribeOrders()
unsubscribeTransactions()
}
}, [profile?.company_id, calculateMetrics])
return {
metrics,
loading,
refetch: calculateMetrics,
}
}
Custom Hook untuk Realtime Notifications
Mari kita buat hook untuk handle realtime notifications:
// hooks/useRealtimeNotifications.ts
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useAuth } from '@/hooks/useAuth'
import { realtimeManager } from '@/lib/supabase/realtime'
import { createClient } from '@/lib/supabase/client'
export interface Notification {
id: string
title: string
message: string
type: 'info' | 'success' | 'warning' | 'error'
category: 'order' | 'payment' | 'system' | 'marketing'
data: Record<string, any>
is_read: boolean
created_at: string
}
export function useRealtimeNotifications() {
const [notifications, setNotifications] = useState<Notification[]>([])
const [unreadCount, setUnreadCount] = useState(0)
const [loading, setLoading] = useState(true)
const { profile } = useAuth()
const supabase = createClient()
const fetchNotifications = useCallback(async () => {
if (!profile?.company_id) return
try {
setLoading(true)
const { data, error } = await supabase
.from('notifications')
.select('*')
.eq('company_id', profile.company_id)
.order('created_at', { ascending: false })
.limit(50)
if (error) throw error
setNotifications(data || [])
setUnreadCount(data?.filter(n => !n.is_read).length || 0)
} catch (error) {
console.error('Error fetching notifications:', error)
} finally {
setLoading(false)
}
}, [profile?.company_id, supabase])
// Setup realtime subscription
useEffect(() => {
if (!profile?.company_id) return
fetchNotifications()
const unsubscribe = realtimeManager.subscribe(
{
table: 'notifications',
filter: `company_id=eq.${profile.company_id}`,
},
(payload) => {
switch (payload.eventType) {
case 'INSERT':
handleNewNotification(payload.new as Notification)
break
case 'UPDATE':
handleNotificationUpdate(payload.new as Notification)
break
case 'DELETE':
handleNotificationDelete(payload.old as Notification)
break
}
}
)
return unsubscribe
}, [profile?.company_id, fetchNotifications])
const handleNewNotification = useCallback((newNotification: Notification) => {
setNotifications(prev => [newNotification, ...prev.slice(0, 49)]) // Keep only 50 latest
setUnreadCount(prev => prev + 1)
// Show browser notification jika permission granted
showBrowserNotification(newNotification)
}, [])
const handleNotificationUpdate = useCallback((updatedNotification: Notification) => {
setNotifications(prev =>
prev.map(n => n.id === updatedNotification.id ? updatedNotification : n)
)
// Update unread count
setUnreadCount(prev => {
const oldNotification = notifications.find(n => n.id === updatedNotification.id)
if (oldNotification?.is_read !== updatedNotification.is_read) {
return updatedNotification.is_read ? prev - 1 : prev + 1
}
return prev
})
}, [notifications])
const handleNotificationDelete = useCallback((deletedNotification: Notification) => {
setNotifications(prev => prev.filter(n => n.id !== deletedNotification.id))
if (!deletedNotification.is_read) {
setUnreadCount(prev => prev - 1)
}
}, [])
const markAsRead = useCallback(async (notificationId: string) => {
try {
const { error } = await supabase
.from('notifications')
.update({ is_read: true })
.eq('id', notificationId)
if (error) throw error
// Optimistic update
setNotifications(prev =>
prev.map(n => n.id === notificationId ? { ...n, is_read: true } : n)
)
setUnreadCount(prev => Math.max(0, prev - 1))
} catch (error) {
console.error('Error marking notification as read:', error)
}
}, [supabase])
const markAllAsRead = useCallback(async () => {
try {
const { error } = await supabase
.from('notifications')
.update({ is_read: true })
.eq('company_id', profile?.company_id)
.eq('is_read', false)
if (error) throw error
// Optimistic update
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })))
setUnreadCount(0)
} catch (error) {
console.error('Error marking all notifications as read:', error)
}
}, [supabase, profile?.company_id])
const deleteNotification = useCallback(async (notificationId: string) => {
try {
const { error } = await supabase
.from('notifications')
.delete()
.eq('id', notificationId)
if (error) throw error
// Optimistic update
const notification = notifications.find(n => n.id === notificationId)
setNotifications(prev => prev.filter(n => n.id !== notificationId))
if (notification && !notification.is_read) {
setUnreadCount(prev => Math.max(0, prev - 1))
}
} catch (error) {
console.error('Error deleting notification:', error)
}
}, [supabase, notifications])
return {
notifications,
unreadCount,
loading,
markAsRead,
markAllAsRead,
deleteNotification,
refetch: fetchNotifications,
}
}
// Browser notification helper
function showBrowserNotification(notification: Notification) {
if ('Notification' in window && Notification.permission === 'granted') {
const browserNotification = new Notification(notification.title, {
body: notification.message,
icon: '/favicon.ico',
badge: '/favicon.ico',
tag: notification.id,
data: notification.data,
requireInteraction: notification.type === 'error',
})
// Auto close after 5 seconds (kecuali error notifications)
if (notification.type !== 'error') {
setTimeout(() => browserNotification.close(), 5000)
}
browserNotification.onclick = () => {
window.focus()
browserNotification.close()
// Navigate to relevant page berdasarkan notification data
if (notification.data.order_id) {
window.location.href = `/dashboard/orders/${notification.data.order_id}`
}
}
}
}
Push Notifications dan Service Worker Setup
Mari kita implement push notifications dengan service worker:
// public/sw.js (Service Worker)
const CACHE_NAME = 'laundrypro-v1'
const urlsToCache = [
'/',
'/dashboard',
'/static/js/bundle.js',
'/static/css/main.css',
'/manifest.json'
]
// Install event
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
return cache.addAll(urlsToCache)
})
)
})
// Fetch event untuk caching
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((response) => {
// Return cached version atau fetch dari network
return response || fetch(event.request)
})
)
})
// Push event untuk notifications
self.addEventListener('push', (event) => {
console.log('Push event received:', event)
if (event.data) {
const data = event.data.json()
const options = {
body: data.body || data.message,
icon: '/favicon.ico',
badge: '/favicon.ico',
data: data.data || {},
actions: [
{
action: 'view',
title: 'View Details'
},
{
action: 'dismiss',
title: 'Dismiss'
}
],
requireInteraction: data.priority === 'high',
tag: data.tag || 'default'
}
event.waitUntil(
self.registration.showNotification(data.title, options)
)
}
})
// Notification click event
self.addEventListener('notificationclick', (event) => {
console.log('Notification clicked:', event)
event.notification.close()
if (event.action === 'view') {
// Open specific page berdasarkan notification data
const urlToOpen = event.notification.data.url || '/'
event.waitUntil(
clients.openWindow(urlToOpen)
)
}
})
// Background sync untuk offline actions
self.addEventListener('sync', (event) => {
if (event.tag === 'background-sync') {
event.waitUntil(doBackgroundSync())
}
})
async function doBackgroundSync() {
// Handle queued actions saat kembali online
const syncData = await getQueuedActions()
for (const action of syncData) {
try {
await fetch(action.url, {
method: action.method,
headers: action.headers,
body: action.body
})
// Remove from queue setelah berhasil
await removeFromQueue(action.id)
} catch (error) {
console.error('Background sync failed for action:', action.id, error)
}
}
}
// Helper functions untuk queue management
async function getQueuedActions() {
// Implementation untuk get queued actions dari IndexedDB
return []
}
async function removeFromQueue(actionId) {
// Implementation untuk remove action dari queue
}
Browser Notification Manager
Mari kita buat manager untuk handle browser notifications:
// lib/notifications/browser-notifications.ts
export class BrowserNotificationManager {
private static instance: BrowserNotificationManager
private permission: NotificationPermission = 'default'
private constructor() {
if ('Notification' in window) {
this.permission = Notification.permission
}
}
static getInstance(): BrowserNotificationManager {
if (!BrowserNotificationManager.instance) {
BrowserNotificationManager.instance = new BrowserNotificationManager()
}
return BrowserNotificationManager.instance
}
async requestPermission(): Promise<NotificationPermission> {
if (!('Notification' in window)) {
console.warn('This browser does not support notifications')
return 'denied'
}
if (this.permission === 'default') {
const permission = await Notification.requestPermission()
this.permission = permission
return permission
}
return this.permission
}
async showNotification(
title: string,
options: NotificationOptions & {
onClick?: () => void
onClose?: () => void
} = {}
): Promise<void> {
const permission = await this.requestPermission()
if (permission !== 'granted') {
console.warn('Notification permission not granted')
return
}
const { onClick, onClose, ...notificationOptions } = options
const notification = new Notification(title, {
icon: '/favicon.ico',
badge: '/favicon.ico',
...notificationOptions,
})
if (onClick) {
notification.onclick = () => {
onClick()
notification.close()
}
}
if (onClose) {
notification.onclose = onClose
}
// Auto close after 5 seconds jika tidak requireInteraction
if (!options.requireInteraction) {
setTimeout(() => notification.close(), 5000)
}
}
isSupported(): boolean {
return 'Notification' in window
}
getPermission(): NotificationPermission {
return this.permission
}
}
export const notificationManager = BrowserNotificationManager.getInstance()
Service Worker Registration
Mari kita setup service worker registration:
// lib/service-worker.ts
export async function registerServiceWorker() {
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.register('/sw.js')
console.log('Service Worker registered successfully:', registration)
// Check untuk updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New service worker available
console.log('New service worker available')
// Optionally show update notification to user
}
})
}
})
return registration
} catch (error) {
console.error('Service Worker registration failed:', error)
}
}
}
// Queue actions untuk offline support
interface QueuedAction {
id: string
url: string
method: string
headers: Record<string, string>
body?: string
timestamp: number
}
export class OfflineActionQueue {
private static instance: OfflineActionQueue
private dbName = 'laundrypro-offline'
private storeName = 'actions'
private db: IDBDatabase | null = null
private constructor() {
this.initDB()
}
static getInstance(): OfflineActionQueue {
if (!OfflineActionQueue.instance) {
OfflineActionQueue.instance = new OfflineActionQueue()
}
return OfflineActionQueue.instance
}
private async initDB(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1)
request.onerror = () => reject(request.error)
request.onsuccess = () => {
this.db = request.result
resolve()
}
request.onupgradeneeded = () => {
const db = request.result
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName, { keyPath: 'id' })
}
}
})
}
async queueAction(action: Omit<QueuedAction, 'id' | 'timestamp'>): Promise<void> {
if (!this.db) await this.initDB()
const queuedAction: QueuedAction = {
id: crypto.randomUUID(),
timestamp: Date.now(),
...action
}
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction([this.storeName], 'readwrite')
const store = transaction.objectStore(this.storeName)
const request = store.add(queuedAction)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve()
})
}
async getQueuedActions(): Promise<QueuedAction[]> {
if (!this.db) await this.initDB()
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction([this.storeName], 'readonly')
const store = transaction.objectStore(this.storeName)
const request = store.getAll()
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result)
})
}
async removeAction(id: string): Promise<void> {
if (!this.db) await this.initDB()
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction([this.storeName], 'readwrite')
const store = transaction.objectStore(this.storeName)
const request = store.delete(id)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve()
})
}
}
export const offlineQueue = OfflineActionQueue.getInstance()
Real-time Dashboard Component
Mari kita buat dashboard component yang showcase semua real-time features:
// components/dashboard/RealtimeDashboard.tsx
'use client'
import { useEffect } from 'react'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Bell, ShoppingCart, DollarSign, Users, TrendingUp } from 'lucide-react'
import { useRealtimeDashboard } from '@/hooks/useRealtimeDashboard'
import { useRealtimeOrders } from '@/hooks/useRealtimeOrders'
import { useRealtimeNotifications } from '@/hooks/useRealtimeNotifications'
import { formatPrice } from '@/lib/stripe'
import { registerServiceWorker } from '@/lib/service-worker'
import { notificationManager } from '@/lib/notifications/browser-notifications'
export function RealtimeDashboard() {
const { metrics, loading: metricsLoading } = useRealtimeDashboard()
const { orders, updateOrderStatus, optimisticUpdates } = useRealtimeOrders({
status: 'pending',
autoRefresh: true
})
const { notifications, unreadCount, markAsRead } = useRealtimeNotifications()
// Setup service worker dan notifications
useEffect(() => {
registerServiceWorker()
notificationManager.requestPermission()
}, [])
const handleStatusUpdate = async (orderId: string, newStatus: string) => {
try {
await updateOrderStatus(orderId, newStatus)
} catch (error) {
console.error('Failed to update order status:', error)
}
}
return (
<div className="space-y-6">
{/* Metrics Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Orders</CardTitle>
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{metricsLoading ? '-' : metrics.totalOrders.toLocaleString()}
</div>
<p className="text-xs text-muted-foreground">
{metrics.pendingOrders} pending
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Today's Revenue</CardTitle>
<DollarSign className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{metricsLoading ? '-' : formatPrice(metrics.todayRevenue)}
</div>
<p className="text-xs text-muted-foreground">
{metrics.todayOrders} orders today
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Customers</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{metricsLoading ? '-' : metrics.activeCustomers.toLocaleString()}
</div>
<p className="text-xs text-muted-foreground">
Last 30 days
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Avg Order Value</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{metricsLoading ? '-' : formatPrice(metrics.averageOrderValue)}
</div>
<p className="text-xs text-muted-foreground">
Per completed order
</p>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Recent Orders */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
Recent Orders
{optimisticUpdates && (
<Badge variant="secondary" className="text-xs">
Updating...
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{orders.slice(0, 5).map((order) => (
<div key={order.id} className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex-1">
<p className="font-medium">{order.order_number}</p>
<p className="text-sm text-muted-foreground">
{order.customer?.full_name} • {formatPrice(order.total_amount)}
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant={order.status === 'pending' ? 'secondary' : 'default'}>
{order.status}
</Badge>
{order.status === 'pending' && (
<Button
size="sm"
onClick={() => handleStatusUpdate(order.id, 'confirmed')}
>
Confirm
</Button>
)}
</div>
</div>
))}
{orders.length === 0 && (
<p className="text-center text-muted-foreground py-4">
No pending orders
</p>
)}
</div>
</CardContent>
</Card>
{/* Recent Notifications */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="h-5 w-5" />
Recent Notifications
{unreadCount > 0 && (
<Badge variant="destructive" className="text-xs">
{unreadCount}
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{notifications.slice(0, 5).map((notification) => (
<div
key={notification.id}
className={`p-3 border rounded-lg cursor-pointer transition-colors ${
!notification.is_read ? 'bg-blue-50 border-blue-200' : 'hover:bg-muted'
}`}
onClick={() => !notification.is_read && markAsRead(notification.id)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="font-medium text-sm">{notification.title}</p>
<p className="text-sm text-muted-foreground">{notification.message}</p>
<p className="text-xs text-muted-foreground mt-1">
{new Date(notification.created_at).toLocaleString()}
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant={notification.type === 'error' ? 'destructive' : 'secondary'}>
{notification.type}
</Badge>
{!notification.is_read && (
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
)}
</div>
</div>
</div>
))}
{notifications.length === 0 && (
<p className="text-center text-muted-foreground py-4">
No notifications
</p>
)}
</div>
</CardContent>
</Card>
</div>
</div>
)
}
Network Status Manager
Mari kita buat manager untuk handle online/offline status:
// hooks/useNetworkStatus.ts
'use client'
import { useState, useEffect } from 'react'
import { offlineQueue } from '@/lib/service-worker'
import { toast } from 'sonner'
export function useNetworkStatus() {
const [isOnline, setIsOnline] = useState(true)
const [queuedActions, setQueuedActions] = useState(0)
useEffect(() => {
// Initial status
setIsOnline(navigator.onLine)
const handleOnline = async () => {
setIsOnline(true)
toast.success('Connection restored')
// Process queued actions
const actions = await offlineQueue.getQueuedActions()
setQueuedActions(actions.length)
if (actions.length > 0) {
toast.info(`Processing ${actions.length} queued actions...`)
// Trigger background sync jika supported
if ('serviceWorker' in navigator && 'sync' in window.ServiceWorkerRegistration.prototype) {
const registration = await navigator.serviceWorker.ready
await registration.sync.register('background-sync')
}
}
}
const handleOffline = () => {
setIsOnline(false)
toast.warning('Connection lost. Actions will be queued.')
}
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
const queueOfflineAction = async (action: {
url: string
method: string
headers: Record<string, string>
body?: string
}) => {
await offlineQueue.queueAction(action)
setQueuedActions(prev => prev + 1)
toast.info('Action queued for when connection is restored')
}
return {
isOnline,
queuedActions,
queueOfflineAction,
}
}
Sistem Automated Communication untuk User Engagement
Alright teman-teman! Sekarang kita bakal build comprehensive communication system yang bikin users tetap engaged dan informed sepanjang customer journey mereka. Dari welcome emails yang beautiful sampai WhatsApp notifications yang instant - we got it all covered! 🚀
Setup Email Service dengan Resend
Mari kita mulai dengan setup email service menggunakan Resend yang modern dan developer-friendly:
npm install resend @react-email/components @react-email/render
npm install --save-dev @types/react
Setup environment variables:
# Email Configuration
RESEND_API_KEY=re_your_resend_api_key_here
[email protected]
[email protected]
# SMS/WhatsApp Configuration
TWILIO_ACCOUNT_SID=your_twilio_account_sid
TWILIO_AUTH_TOKEN=your_twilio_auth_token
TWILIO_PHONE_NUMBER=+1234567890
WHATSAPP_PHONE_NUMBER=whatsapp:+1234567890
# App Configuration
APP_NAME=LaundryPro
APP_URL=https://yourdomain.com
Konfigurasi Resend client:
// lib/email/resend.ts
import { Resend } from 'resend'
if (!process.env.RESEND_API_KEY) {
throw new Error('RESEND_API_KEY is not configured')
}
export const resend = new Resend(process.env.RESEND_API_KEY)
export const emailConfig = {
from: process.env.FROM_EMAIL || '[email protected]',
supportEmail: process.env.SUPPORT_EMAIL || '[email protected]',
appName: process.env.APP_NAME || 'LaundryPro',
appUrl: process.env.APP_URL || '<https://yourdomain.com>',
}
// Email template types
export interface EmailTemplate {
to: string | string[]
subject: string
html: string
text?: string
attachments?: Array<{
filename: string
content: Buffer | string
contentType?: string
}>
tags?: Array<{
name: string
value: string
}>
}
// Send email with error handling dan retry logic
export async function sendEmail(template: EmailTemplate) {
try {
const response = await resend.emails.send({
from: emailConfig.from,
to: template.to,
subject: template.subject,
html: template.html,
text: template.text,
attachments: template.attachments,
tags: template.tags,
})
console.log('Email sent successfully:', response.data?.id)
return { success: true, id: response.data?.id }
} catch (error: any) {
console.error('Failed to send email:', error)
return { success: false, error: error.message }
}
}
// Bulk email sending dengan rate limiting
export async function sendBulkEmails(templates: EmailTemplate[], batchSize = 10) {
const results = []
for (let i = 0; i < templates.length; i += batchSize) {
const batch = templates.slice(i, i + batchSize)
const batchPromises = batch.map(template => sendEmail(template))
const batchResults = await Promise.allSettled(batchPromises)
results.push(...batchResults)
// Rate limiting - wait 1 second between batches
if (i + batchSize < templates.length) {
await new Promise(resolve => setTimeout(resolve, 1000))
}
}
return results
}
Beautiful Email Templates dengan React Email
Mari kita buat email templates yang professional dan responsive:
// emails/WelcomeEmail.tsx
import {
Body,
Button,
Container,
Head,
Heading,
Html,
Img,
Link,
Preview,
Section,
Text,
Tailwind,
} from '@react-email/components'
interface WelcomeEmailProps {
userFirstName: string
companyName: string
loginUrl: string
supportEmail: string
}
export const WelcomeEmail = ({
userFirstName = 'there',
companyName = 'Your Company',
loginUrl = '<https://yourdomain.com/login>',
supportEmail = '[email protected]',
}: WelcomeEmailProps) => {
return (
<Html>
<Head />
<Preview>Welcome to LaundryPro - Let's get your laundry business organized!</Preview>
<Tailwind>
<Body className="bg-white my-auto mx-auto font-sans">
<Container className="border border-solid border-[#eaeaea] rounded my-[40px] mx-auto p-[20px] w-[465px]">
<Section className="mt-[32px]">
<Img
src="<https://yourdomain.com/logo.png>"
width="160"
height="48"
alt="LaundryPro"
className="my-0 mx-auto"
/>
</Section>
<Heading className="text-black text-[24px] font-normal text-center p-0 my-[30px] mx-0">
Welcome to <strong>LaundryPro</strong>
</Heading>
<Text className="text-black text-[15px] leading-[24px]">
Hello {userFirstName},
</Text>
<Text className="text-black text-[15px] leading-[24px]">
Welcome to LaundryPro! We're excited to help you streamline your laundry business
operations with our comprehensive management platform.
</Text>
<Text className="text-black text-[15px] leading-[24px]">
Your company <strong>{companyName}</strong> has been successfully set up.
Here's what you can do next:
</Text>
<Section className="my-[32px]">
<ul className="text-black text-[15px] leading-[24px] pl-0">
<li className="mb-2">✅ Set up your laundry branches</li>
<li className="mb-2">✅ Add your team members</li>
<li className="mb-2">✅ Configure your services and pricing</li>
<li className="mb-2">✅ Start processing orders</li>
</ul>
</Section>
<Section className="text-center mt-[32px] mb-[32px]">
<Button
className="bg-[#000000] rounded text-white text-[12px] font-semibold no-underline text-center px-5 py-3"
href={loginUrl}
>
Get Started
</Button>
</Section>
<Text className="text-black text-[15px] leading-[24px]">
If you have any questions or need help getting started, our support team is here to help.
</Text>
<Text className="text-black text-[15px] leading-[24px]">
Best regards,<br />
The LaundryPro Team
</Text>
<Section className="mt-[32px] pt-[32px] border-t border-solid border-[#eaeaea]">
<Text className="text-[#666666] text-[12px] leading-[24px] text-center">
Need help? Contact us at{' '}
<Link href={`mailto:${supportEmail}`} className="text-blue-600">
{supportEmail}
</Link>
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
)
}
export default WelcomeEmail
// emails/PaymentConfirmationEmail.tsx
import {
Body,
Button,
Container,
Head,
Heading,
Html,
Img,
Preview,
Section,
Text,
Tailwind,
Row,
Column,
} from '@react-email/components'
interface PaymentConfirmationEmailProps {
userFirstName: string
planName: string
amount: string
billingPeriod: string
nextBillingDate: string
invoiceUrl: string
manageSubscriptionUrl: string
}
export const PaymentConfirmationEmail = ({
userFirstName = 'there',
planName = 'Professional',
amount = 'Rp 199,000',
billingPeriod = 'monthly',
nextBillingDate = 'February 15, 2024',
invoiceUrl = '#',
manageSubscriptionUrl = '#',
}: PaymentConfirmationEmailProps) => {
return (
<Html>
<Head />
<Preview>Payment confirmation for your LaundryPro subscription</Preview>
<Tailwind>
<Body className="bg-white my-auto mx-auto font-sans">
<Container className="border border-solid border-[#eaeaea] rounded my-[40px] mx-auto p-[20px] w-[465px]">
<Section className="mt-[32px]">
<Img
src="<https://yourdomain.com/logo.png>"
width="160"
height="48"
alt="LaundryPro"
className="my-0 mx-auto"
/>
</Section>
<Section className="text-center mt-[32px] mb-[32px]">
<div className="bg-green-100 rounded-full w-16 h-16 flex items-center justify-center mx-auto mb-4">
<Text className="text-green-600 text-[24px] font-bold m-0">✓</Text>
</div>
<Heading className="text-black text-[24px] font-normal m-0">
Payment Successful
</Heading>
</Section>
<Text className="text-black text-[15px] leading-[24px]">
Hello {userFirstName},
</Text>
<Text className="text-black text-[15px] leading-[24px]">
Thank you for your payment! Your subscription has been successfully renewed.
</Text>
<Section className="bg-[#f6f6f6] rounded p-[20px] my-[32px]">
<Heading className="text-black text-[18px] font-semibold m-0 mb-4">
Payment Details
</Heading>
<Row>
<Column>
<Text className="text-[#666666] text-[12px] m-0">Plan</Text>
<Text className="text-black text-[15px] font-semibold m-0">{planName}</Text>
</Column>
<Column align="right">
<Text className="text-[#666666] text-[12px] m-0">Amount</Text>
<Text className="text-black text-[15px] font-semibold m-0">{amount}</Text>
</Column>
</Row>
<Row className="mt-4">
<Column>
<Text className="text-[#666666] text-[12px] m-0">Billing Period</Text>
<Text className="text-black text-[15px] font-semibold m-0 capitalize">{billingPeriod}</Text>
</Column>
<Column align="right">
<Text className="text-[#666666] text-[12px] m-0">Next Billing</Text>
<Text className="text-black text-[15px] font-semibold m-0">{nextBillingDate}</Text>
</Column>
</Row>
</Section>
<Section className="text-center mt-[32px] mb-[32px]">
<Row>
<Column className="pr-2">
<Button
className="bg-white border border-solid border-[#eaeaea] rounded text-black text-[12px] font-semibold no-underline text-center px-4 py-3 w-full"
href={invoiceUrl}
>
Download Invoice
</Button>
</Column>
<Column className="pl-2">
<Button
className="bg-[#000000] rounded text-white text-[12px] font-semibold no-underline text-center px-4 py-3 w-full"
href={manageSubscriptionUrl}
>
Manage Subscription
</Button>
</Column>
</Row>
</Section>
<Text className="text-black text-[15px] leading-[24px]">
Your subscription will automatically renew on {nextBillingDate}.
You can update your billing information or cancel anytime from your account settings.
</Text>
<Text className="text-black text-[15px] leading-[24px]">
Thank you for choosing LaundryPro!
</Text>
</Container>
</Body>
</Tailwind>
</Html>
)
}
export default PaymentConfirmationEmail
// emails/OrderNotificationEmail.tsx
import {
Body,
Button,
Container,
Head,
Heading,
Html,
Img,
Preview,
Section,
Text,
Tailwind,
Row,
Column,
} from '@react-email/components'
interface OrderNotificationEmailProps {
customerName: string
orderNumber: string
status: string
estimatedCompletion?: string
trackingUrl: string
laundryName: string
laundryPhone: string
}
export const OrderNotificationEmail = ({
customerName = 'Customer',
orderNumber = 'LND-20240115-001',
status = 'in_progress',
estimatedCompletion = 'Tomorrow at 2:00 PM',
trackingUrl = '#',
laundryName = 'LaundryPro Central',
laundryPhone = '+62 21 1234567',
}: OrderNotificationEmailProps) => {
const getStatusColor = (status: string) => {
switch (status) {
case 'confirmed': return '#3b82f6' // blue
case 'in_progress': return '#f59e0b' // yellow
case 'ready': return '#10b981' // green
case 'completed': return '#059669' // emerald
default: return '#6b7280' // gray
}
}
const getStatusMessage = (status: string) => {
switch (status) {
case 'confirmed': return 'Your order has been confirmed and will be processed soon.'
case 'in_progress': return 'Your laundry is currently being processed.'
case 'ready': return 'Great news! Your laundry is ready for pickup.'
case 'completed': return 'Your order has been completed. Thank you for using our service!'
default: return 'Your order status has been updated.'
}
}
return (
<Html>
<Head />
<Preview>Order {orderNumber} - Status Update</Preview>
<Tailwind>
<Body className="bg-white my-auto mx-auto font-sans">
<Container className="border border-solid border-[#eaeaea] rounded my-[40px] mx-auto p-[20px] w-[465px]">
<Section className="mt-[32px]">
<Img
src="<https://yourdomain.com/logo.png>"
width="160"
height="48"
alt="LaundryPro"
className="my-0 mx-auto"
/>
</Section>
<Heading className="text-black text-[24px] font-normal text-center p-0 my-[30px] mx-0">
Order Status Update
</Heading>
<Text className="text-black text-[15px] leading-[24px]">
Hello {customerName},
</Text>
<Text className="text-black text-[15px] leading-[24px]">
{getStatusMessage(status)}
</Text>
<Section className="bg-[#f6f6f6] rounded p-[20px] my-[32px]">
<Row>
<Column>
<Text className="text-[#666666] text-[12px] m-0">Order Number</Text>
<Text className="text-black text-[16px] font-semibold m-0">{orderNumber}</Text>
</Column>
<Column align="right">
<Text className="text-[#666666] text-[12px] m-0">Status</Text>
<div style={{
backgroundColor: getStatusColor(status),
color: 'white',
padding: '4px 12px',
borderRadius: '16px',
fontSize: '12px',
fontWeight: 'bold',
textTransform: 'capitalize',
display: 'inline-block'
}}>
{status.replace('_', ' ')}
</div>
</Column>
</Row>
{estimatedCompletion && (
<Row className="mt-4">
<Column>
<Text className="text-[#666666] text-[12px] m-0">Estimated Completion</Text>
<Text className="text-black text-[15px] font-semibold m-0">{estimatedCompletion}</Text>
</Column>
</Row>
)}
<Row className="mt-4">
<Column>
<Text className="text-[#666666] text-[12px] m-0">Laundry Location</Text>
<Text className="text-black text-[15px] font-semibold m-0">{laundryName}</Text>
<Text className="text-[#666666] text-[12px] m-0">{laundryPhone}</Text>
</Column>
</Row>
</Section>
<Section className="text-center mt-[32px] mb-[32px]">
<Button
className="bg-[#000000] rounded text-white text-[12px] font-semibold no-underline text-center px-5 py-3"
href={trackingUrl}
>
Track Your Order
</Button>
</Section>
{status === 'ready' && (
<Section className="bg-green-50 border border-green-200 rounded p-[16px] my-[32px]">
<Text className="text-green-800 text-[15px] leading-[24px] m-0">
<strong>📍 Ready for Pickup!</strong><br />
Your laundry is ready! Please bring this email or your order number when picking up.
</Text>
</Section>
)}
<Text className="text-black text-[15px] leading-[24px]">
Thank you for choosing our laundry service. If you have any questions,
please don't hesitate to contact us.
</Text>
</Container>
</Body>
</Tailwind>
</Html>
)
}
export default OrderNotificationEmail
Email Queue System dengan Database
Mari kita implement email queue system yang reliable:
-- Create email_queue table
CREATE TABLE email_queue (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
company_id UUID REFERENCES companies(id) ON DELETE CASCADE,
to_email TEXT NOT NULL,
from_email TEXT NOT NULL,
subject TEXT NOT NULL,
html_content TEXT NOT NULL,
text_content TEXT,
template_name TEXT,
template_data JSONB DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'sent', 'failed', 'cancelled')),
priority INTEGER DEFAULT 1, -- 1 = low, 2 = normal, 3 = high, 4 = urgent
scheduled_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
attempts INTEGER DEFAULT 0,
max_attempts INTEGER DEFAULT 3,
last_attempt_at TIMESTAMP WITH TIME ZONE,
error_message TEXT,
sent_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes untuk performance
CREATE INDEX idx_email_queue_status ON email_queue(status);
CREATE INDEX idx_email_queue_scheduled_at ON email_queue(scheduled_at);
CREATE INDEX idx_email_queue_priority ON email_queue(priority DESC);
CREATE INDEX idx_email_queue_company_id ON email_queue(company_id);
-- Trigger untuk auto-update updated_at
CREATE TRIGGER update_email_queue_updated_at BEFORE UPDATE ON email_queue
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
// lib/email/queue.ts
import { createRouteHandlerSupabaseClient } from '@/lib/supabase/server'
import { cookies } from 'next/headers'
import { render } from '@react-email/render'
import { sendEmail } from './resend'
export interface QueueEmailOptions {
to: string
subject: string
template?: string
templateData?: Record<string, any>
htmlContent?: string
textContent?: string
priority?: 1 | 2 | 3 | 4
scheduledAt?: Date
companyId?: string
}
export async function queueEmail(options: QueueEmailOptions) {
const supabase = createRouteHandlerSupabaseClient({ cookies })
try {
const { error } = await supabase
.from('email_queue')
.insert({
company_id: options.companyId,
to_email: options.to,
from_email: process.env.FROM_EMAIL!,
subject: options.subject,
html_content: options.htmlContent || '',
text_content: options.textContent,
template_name: options.template,
template_data: options.templateData || {},
priority: options.priority || 2,
scheduled_at: options.scheduledAt?.toISOString() || new Date().toISOString(),
})
if (error) throw error
console.log(`Email queued for ${options.to}`)
return { success: true }
} catch (error: any) {
console.error('Failed to queue email:', error)
return { success: false, error: error.message }
}
}
// Process email queue
export async function processEmailQueue(batchSize = 10) {
const supabase = createRouteHandlerSupabaseClient({ cookies })
try {
// Get pending emails yang sudah scheduled
const { data: emails, error } = await supabase
.from('email_queue')
.select('*')
.eq('status', 'pending')
.lte('scheduled_at', new Date().toISOString())
.lt('attempts', supabase.rpc('max_attempts'))
.order('priority', { ascending: false })
.order('scheduled_at', { ascending: true })
.limit(batchSize)
if (error) throw error
const results = []
for (const email of emails || []) {
try {
// Mark as processing
await supabase
.from('email_queue')
.update({
status: 'processing',
attempts: email.attempts + 1,
last_attempt_at: new Date().toISOString()
})
.eq('id', email.id)
// Render template jika ada
let htmlContent = email.html_content
if (email.template_name && email.template_data) {
htmlContent = await renderEmailTemplate(email.template_name, email.template_data)
}
// Send email
const result = await sendEmail({
to: email.to_email,
subject: email.subject,
html: htmlContent,
text: email.text_content,
tags: [
{ name: 'template', value: email.template_name || 'custom' },
{ name: 'company_id', value: email.company_id || 'system' }
]
})
if (result.success) {
// Mark as sent
await supabase
.from('email_queue')
.update({
status: 'sent',
sent_at: new Date().toISOString(),
error_message: null
})
.eq('id', email.id)
results.push({ id: email.id, status: 'sent' })
} else {
throw new Error(result.error)
}
} catch (error: any) {
console.error(`Failed to send email ${email.id}:`, error)
// Check if max attempts reached
const newAttempts = email.attempts + 1
const status = newAttempts >= email.max_attempts ? 'failed' : 'pending'
await supabase
.from('email_queue')
.update({
status,
error_message: error.message,
})
.eq('id', email.id)
results.push({ id: email.id, status: 'error', error: error.message })
}
}
return { success: true, processed: results.length, results }
} catch (error: any) {
console.error('Failed to process email queue:', error)
return { success: false, error: error.message }
}
}
// Render email template
async function renderEmailTemplate(templateName: string, data: Record<string, any>) {
try {
switch (templateName) {
case 'welcome':
const { default: WelcomeEmail } = await import('../../emails/WelcomeEmail')
return render(WelcomeEmail(data))
case 'payment_confirmation':
const { default: PaymentEmail } = await import('../../emails/PaymentConfirmationEmail')
return render(PaymentEmail(data))
case 'order_notification':
const { default: OrderEmail } = await import('../../emails/OrderNotificationEmail')
return render(OrderEmail(data))
default:
throw new Error(`Unknown template: ${templateName}`)
}
} catch (error: any) {
console.error(`Failed to render template ${templateName}:`, error)
throw error
}
}
// Email service class untuk easy usage
export class EmailService {
static async sendWelcomeEmail(to: string, data: {
userFirstName: string
companyName: string
loginUrl: string
}) {
return queueEmail({
to,
subject: `Welcome to ${process.env.APP_NAME || 'LaundryPro'}!`,
template: 'welcome',
templateData: {
...data,
supportEmail: process.env.SUPPORT_EMAIL,
},
priority: 3
})
}
static async sendPaymentConfirmation(to: string, data: {
userFirstName: string
planName: string
amount: string
billingPeriod: string
nextBillingDate: string
invoiceUrl: string
manageSubscriptionUrl: string
}) {
return queueEmail({
to,
subject: 'Payment Confirmation - Thank You!',
template: 'payment_confirmation',
templateData: data,
priority: 3
})
}
static async sendOrderNotification(to: string, data: {
customerName: string
orderNumber: string
status: string
estimatedCompletion?: string
trackingUrl: string
laundryName: string
laundryPhone: string
}) {
return queueEmail({
to,
subject: `Order ${data.orderNumber} - Status Update`,
template: 'order_notification',
templateData: data,
priority: data.status === 'ready' ? 4 : 2
})
}
}
SMS/WhatsApp Integration dengan Twilio
Mari kita implement SMS dan WhatsApp notifications:
// lib/sms/twilio.ts
import twilio from 'twilio'
if (!process.env.TWILIO_ACCOUNT_SID || !process.env.TWILIO_AUTH_TOKEN) {
throw new Error('Twilio credentials are not configured')
}
const client = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN)
export interface SMSOptions {
to: string
message: string
mediaUrl?: string[]
}
export interface WhatsAppOptions {
to: string
message: string
mediaUrl?: string[]
template?: {
sid: string
variables?: Record<string, string>
}
}
export async function sendSMS(options: SMSOptions) {
try {
const message = await client.messages.create({
body: options.message,
from: process.env.TWILIO_PHONE_NUMBER,
to: options.to,
mediaUrl: options.mediaUrl,
})
console.log(`SMS sent successfully: ${message.sid}`)
return { success: true, sid: message.sid }
} catch (error: any) {
console.error('Failed to send SMS:', error)
return { success: false, error: error.message }
}
}
export async function sendWhatsApp(options: WhatsAppOptions) {
try {
let messageParams: any = {
from: process.env.WHATSAPP_PHONE_NUMBER,
to: `whatsapp:${options.to}`,
}
if (options.template) {
// Use approved template
messageParams.contentSid = options.template.sid
if (options.template.variables) {
messageParams.contentVariables = JSON.stringify(options.template.variables)
}
} else {
// Send freeform message (only dalam 24-hour window)
messageParams.body = options.message
if (options.mediaUrl) {
messageParams.mediaUrl = options.mediaUrl
}
}
const message = await client.messages.create(messageParams)
console.log(`WhatsApp sent successfully: ${message.sid}`)
return { success: true, sid: message.sid }
} catch (error: any) {
console.error('Failed to send WhatsApp:', error)
return { success: false, error: error.message }
}
}
// SMS/WhatsApp queue system
export async function queueSMS(options: SMSOptions & {
companyId?: string
priority?: 1 | 2 | 3 | 4
scheduledAt?: Date
}) {
const supabase = createRouteHandlerSupabaseClient({ cookies })
try {
const { error } = await supabase
.from('sms_queue')
.insert({
company_id: options.companyId,
to_number: options.to,
message: options.message,
media_urls: options.mediaUrl || [],
type: 'sms',
priority: options.priority || 2,
scheduled_at: options.scheduledAt?.toISOString() || new Date().toISOString(),
})
if (error) throw error
return { success: true }
} catch (error: any) {
console.error('Failed to queue SMS:', error)
return { success: false, error: error.message }
}
}
// SMS service class
export class SMSService {
static async sendOrderReady(to: string, data: {
customerName: string
orderNumber: string
laundryName: string
laundryAddress: string
}) {
const message = `Hi ${data.customerName}! Your laundry order ${data.orderNumber} is ready for pickup at ${data.laundryName}. Address: ${data.laundryAddress}. Thank you!`
return sendSMS({ to, message })
}
static async sendPaymentFailed(to: string, data: {
customerName: string
amount: string
retryUrl: string
}) {
const message = `Hi ${data.customerName}, your payment of ${data.amount} failed. Please update your payment method: ${data.retryUrl}`
return sendSMS({ to, message })
}
static async sendWhatsAppOrderReady(to: string, data: {
customerName: string
orderNumber: string
laundryName: string
trackingUrl: string
}) {
const message = `🧺 Great news ${data.customerName}!\\n\\nYour order *${data.orderNumber}* is ready for pickup at ${data.laundryName}.\\n\\nTrack: ${data.trackingUrl}\\n\\nThank you for choosing us! 😊`
return sendWhatsApp({ to, message })
}
}
Database Schema untuk SMS Queue
-- Create sms_queue table
CREATE TABLE sms_queue (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
company_id UUID REFERENCES companies(id) ON DELETE CASCADE,
to_number TEXT NOT NULL,
message TEXT NOT NULL,
media_urls TEXT[] DEFAULT '{}',
type TEXT NOT NULL CHECK (type IN ('sms', 'whatsapp')) DEFAULT 'sms',
template_sid TEXT,
template_variables JSONB DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'sent', 'failed', 'cancelled')),
priority INTEGER DEFAULT 1,
scheduled_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
attempts INTEGER DEFAULT 0,
max_attempts INTEGER DEFAULT 3,
last_attempt_at TIMESTAMP WITH TIME ZONE,
error_message TEXT,
sent_at TIMESTAMP WITH TIME ZONE,
twilio_sid TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_sms_queue_status ON sms_queue(status);
CREATE INDEX idx_sms_queue_scheduled_at ON sms_queue(scheduled_at);
CREATE INDEX idx_sms_queue_priority ON sms_queue(priority DESC);
CREATE INDEX idx_sms_queue_company_id ON sms_queue(company_id);
-- Trigger untuk auto-update updated_at
CREATE TRIGGER update_sms_queue_updated_at BEFORE UPDATE ON sms_queue
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
Supabase Edge Functions untuk Queue Processing
Mari kita buat Edge Functions untuk process email dan SMS queues:
// supabase/functions/process-queues/index.ts
import { serve } from '<https://deno.land/[email protected]/http/server.ts>'
import { createClient } from '<https://esm.sh/@supabase/supabase-js@2>'
const supabase = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
)
serve(async (req) => {
try {
const { type = 'all' } = await req.json()
const results = {
emails: { processed: 0, errors: 0 },
sms: { processed: 0, errors: 0 }
}
// Process email queue
if (type === 'all' || type === 'email') {
const emailResult = await processEmailQueue()
results.emails = emailResult
}
// Process SMS queue
if (type === 'all' || type === 'sms') {
const smsResult = await processSMSQueue()
results.sms = smsResult
}
return new Response(
JSON.stringify({ success: true, results }),
{ headers: { 'Content-Type': 'application/json' } }
)
} catch (error) {
console.error('Queue processing error:', error)
return new Response(
JSON.stringify({ error: error.message }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
)
}
})
async function processEmailQueue() {
// Implementation similar to processEmailQueue function
// Using Deno-compatible modules
let processed = 0
let errors = 0
try {
const { data: emails } = await supabase
.from('email_queue')
.select('*')
.eq('status', 'pending')
.lte('scheduled_at', new Date().toISOString())
.order('priority', { ascending: false })
.limit(10)
for (const email of emails || []) {
try {
// Send email using fetch to Resend API
const response = await fetch('<https://api.resend.com/emails>', {
method: 'POST',
headers: {
'Authorization': `Bearer ${Deno.env.get('RESEND_API_KEY')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
from: email.from_email,
to: email.to_email,
subject: email.subject,
html: email.html_content,
text: email.text_content
})
})
if (response.ok) {
await supabase
.from('email_queue')
.update({
status: 'sent',
sent_at: new Date().toISOString()
})
.eq('id', email.id)
processed++
} else {
throw new Error(`Email send failed: ${response.statusText}`)
}
} catch (error) {
await supabase
.from('email_queue')
.update({
status: 'failed',
error_message: error.message,
attempts: email.attempts + 1
})
.eq('id', email.id)
errors++
}
}
} catch (error) {
console.error('Email queue processing error:', error)
errors++
}
return { processed, errors }
}
async function processSMSQueue() {
let processed = 0
let errors = 0
try {
const { data: messages } = await supabase
.from('sms_queue')
.select('*')
.eq('status', 'pending')
.lte('scheduled_at', new Date().toISOString())
.order('priority', { ascending: false })
.limit(10)
for (const msg of messages || []) {
try {
// Send SMS using Twilio API
const twilioAuth = btoa(`${Deno.env.get('TWILIO_ACCOUNT_SID')}:${Deno.env.get('TWILIO_AUTH_TOKEN')}`)
const formData = new URLSearchParams()
formData.append('Body', msg.message)
formData.append('From', msg.type === 'whatsapp' ?
Deno.env.get('WHATSAPP_PHONE_NUMBER')! :
Deno.env.get('TWILIO_PHONE_NUMBER')!)
formData.append('To', msg.type === 'whatsapp' ?
`whatsapp:${msg.to_number}` :
msg.to_number)
const response = await fetch(
`https://api.twilio.com/2010-04-01/Accounts/${Deno.env.get('TWILIO_ACCOUNT_SID')}/Messages.json`,
{
method: 'POST',
headers: {
'Authorization': `Basic ${twilioAuth}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: formData
}
)
if (response.ok) {
const result = await response.json()
await supabase
.from('sms_queue')
.update({
status: 'sent',
sent_at: new Date().toISOString(),
twilio_sid: result.sid
})
.eq('id', msg.id)
processed++
} else {
throw new Error(`SMS send failed: ${response.statusText}`)
}
} catch (error) {
await supabase
.from('sms_queue')
.update({
status: 'failed',
error_message: error.message,
attempts: msg.attempts + 1
})
.eq('id', msg.id)
errors++
}
}
} catch (error) {
console.error('SMS queue processing error:', error)
errors++
}
return { processed, errors }
}
Cron Job untuk Automated Queue Processing
Setup cron job untuk regularly process queues:
// supabase/functions/cron-queue-processor/index.ts
import { serve } from '<https://deno.land/[email protected]/http/server.ts>'
serve(async (req) => {
// Verify cron secret untuk security
const authHeader = req.headers.get('authorization')
if (authHeader !== `Bearer ${Deno.env.get('CRON_SECRET')}`) {
return new Response('Unauthorized', { status: 401 })
}
try {
// Process both email and SMS queues
const response = await fetch(`${Deno.env.get('SUPABASE_URL')}/functions/v1/process-queues`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${Deno.env.get('SUPABASE_ANON_KEY')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ type: 'all' })
})
const result = await response.json()
console.log('Queue processing completed:', result)
return new Response(
JSON.stringify({ success: true, message: 'Queues processed successfully' }),
{ headers: { 'Content-Type': 'application/json' } }
)
} catch (error) {
console.error('Cron job error:', error)
return new Response(
JSON.stringify({ error: error.message }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
)
}
})
In-App Notification System Enhancement
Mari kita enhance in-app notification system dengan read/unread tracking:
// components/notifications/NotificationCenter.tsx
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardHeader, CardContent } from '@/components/ui/card'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Bell,
BellRing,
Check,
Trash2,
Settings,
X
} from 'lucide-react'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { useRealtimeNotifications } from '@/hooks/useRealtimeNotifications'
import { formatDistanceToNow } from 'date-fns'
export function NotificationCenter() {
const {
notifications,
unreadCount,
markAsRead,
markAllAsRead,
deleteNotification,
loading
} = useRealtimeNotifications()
const [isOpen, setIsOpen] = useState(false)
const getNotificationIcon = (type: string) => {
switch (type) {
case 'success': return '✅'
case 'warning': return '⚠️'
case 'error': return '❌'
default: return 'ℹ️'
}
}
const getNotificationColor = (type: string) => {
switch (type) {
case 'success': return 'border-green-200 bg-green-50'
case 'warning': return 'border-yellow-200 bg-yellow-50'
case 'error': return 'border-red-200 bg-red-50'
default: return 'border-blue-200 bg-blue-50'
}
}
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
{unreadCount > 0 ? (
<BellRing className="h-5 w-5" />
) : (
<Bell className="h-5 w-5" />
)}
{unreadCount > 0 && (
<Badge
variant="destructive"
className="absolute -top-2 -right-2 h-5 w-5 flex items-center justify-center p-0 text-xs"
>
{unreadCount > 9 ? '9+' : unreadCount}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-80 p-0"
align="end"
sideOffset={4}
>
<Card className="border-0 shadow-lg">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<BellRing className="h-5 w-5" />
<h3 className="font-semibold">Notifications</h3>
{unreadCount > 0 && (
<Badge variant="secondary">{unreadCount} new</Badge>
)}
</div>
<div className="flex items-center gap-1">
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={markAllAsRead}
className="text-xs"
>
<Check className="h-3 w-3 mr-1" />
Mark all read
</Button>
)}
<Button variant="ghost" size="icon" className="h-6 w-6">
<Settings className="h-3 w-3" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
</div>
) : notifications.length > 0 ? (
<ScrollArea className="h-96">
<div className="space-y-1 p-2">
{notifications.map((notification) => (
<div
key={notification.id}
className={`relative p-3 rounded-lg border transition-colors cursor-pointer ${
!notification.is_read
? getNotificationColor(notification.type)
: 'border-gray-200 hover:bg-gray-50'
}`}
onClick={() => !notification.is_read && markAsRead(notification.id)}
>
<div className="flex items-start gap-3">
<div className="text-lg">
{getNotificationIcon(notification.type)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between">
<p className="font-medium text-sm leading-5">
{notification.title}
</p>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation()
deleteNotification(notification.id)
}}
>
<X className="h-3 w-3" />
</Button>
</div>
<p className="text-sm text-muted-foreground leading-5 mt-1">
{notification.message}
</p>
<div className="flex items-center justify-between mt-2">
<p className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(notification.created_at), { addSuffix: true })}
</p>
{!notification.is_read && (
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
)}
</div>
</div>
</div>
{/* Click action berdasarkan notification data */}
{notification.data.order_id && (
<div className="mt-2 pt-2 border-t border-gray-200">
<Button
variant="outline"
size="sm"
className="w-full text-xs"
onClick={(e) => {
e.stopPropagation()
window.location.href = `/dashboard/orders/${notification.data.order_id}`
}}
>
View Order
</Button>
</div>
)}
</div>
))}
</div>
</ScrollArea>
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Bell className="h-8 w-8 text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">No notifications yet</p>
<p className="text-xs text-muted-foreground">We'll notify you when something important happens</p>
</div>
)}
</CardContent>
</Card>
</PopoverContent>
</Popover>
)
}
Communication Automation Triggers
Mari kita buat automated triggers untuk different user actions:
// lib/communication/automation.ts
import { EmailService } from '../email/queue'
import { SMSService } from '../sms/twilio'
import { createRouteHandlerSupabaseClient } from '@/lib/supabase/server'
import { cookies } from 'next/headers'
export class CommunicationAutomation {
private static supabase = createRouteHandlerSupabaseClient({ cookies })
// Trigger saat user baru register
static async onUserRegistered(userId: string, userData: {
email: string
fullName: string
companyName: string
}) {
try {
// Send welcome email
await EmailService.sendWelcomeEmail(userData.email, {
userFirstName: userData.fullName.split(' ')[0],
companyName: userData.companyName,
loginUrl: `${process.env.APP_URL}/dashboard`
})
// Create in-app notification
await this.createNotification({
userId,
title: 'Welcome to LaundryPro!',
message: 'Complete your company setup to get started',
type: 'info',
category: 'system'
})
console.log(`Welcome communication sent to ${userData.email}`)
} catch (error) {
console.error('Failed to send welcome communication:', error)
}
}
// Trigger saat payment berhasil
static async onPaymentSucceeded(companyId: string, paymentData: {
userEmail: string
userFirstName: string
planName: string
amount: string
billingPeriod: string
nextBillingDate: string
invoiceUrl: string
}) {
try {
// Send payment confirmation email
await EmailService.sendPaymentConfirmation(paymentData.userEmail, {
...paymentData,
manageSubscriptionUrl: `${process.env.APP_URL}/dashboard/billing`
})
// Create in-app notification for all company users
await this.createCompanyNotification({
companyId,
title: 'Payment Successful',
message: `Your ${paymentData.planName} subscription has been renewed`,
type: 'success',
category: 'payment'
})
console.log(`Payment confirmation sent to ${paymentData.userEmail}`)
} catch (error) {
console.error('Failed to send payment confirmation:', error)
}
}
// Trigger saat order status berubah
static async onOrderStatusChanged(orderId: string, orderData: {
customerEmail?: string
customerPhone?: string
customerName: string
orderNumber: string
oldStatus: string
newStatus: string
estimatedCompletion?: string
laundryName: string
laundryPhone: string
companyId: string
}) {
try {
const trackingUrl = `${process.env.APP_URL}/track/${orderData.orderNumber}`
// Send email notification jika customer punya email
if (orderData.customerEmail) {
await EmailService.sendOrderNotification(orderData.customerEmail, {
customerName: orderData.customerName,
orderNumber: orderData.orderNumber,
status: orderData.newStatus,
estimatedCompletion: orderData.estimatedCompletion,
trackingUrl,
laundryName: orderData.laundryName,
laundryPhone: orderData.laundryPhone
})
}
// Send SMS/WhatsApp untuk status penting
if (orderData.customerPhone && ['ready', 'completed'].includes(orderData.newStatus)) {
if (orderData.newStatus === 'ready') {
// Send WhatsApp untuk order ready
await SMSService.sendWhatsAppOrderReady(orderData.customerPhone, {
customerName: orderData.customerName,
orderNumber: orderData.orderNumber,
laundryName: orderData.laundryName,
trackingUrl
})
}
}
// Create in-app notification untuk staff
await this.createCompanyNotification({
companyId: orderData.companyId,
title: 'Order Status Updated',
message: `Order ${orderData.orderNumber} is now ${orderData.newStatus}`,
type: 'info',
category: 'order',
data: {
order_id: orderId,
order_number: orderData.orderNumber,
old_status: orderData.oldStatus,
new_status: orderData.newStatus
}
})
console.log(`Order notification sent for ${orderData.orderNumber}`)
} catch (error) {
console.error('Failed to send order notification:', error)
}
}
// Trigger saat payment gagal
static async onPaymentFailed(companyId: string, paymentData: {
userEmail: string
userFirstName: string
amount: string
retryUrl: string
userPhone?: string
}) {
try {
// Send urgent email notification
await queueEmail({
to: paymentData.userEmail,
subject: '🚨 Payment Failed - Action Required',
htmlContent: `
<h2>Payment Failed</h2>
<p>Hi ${paymentData.userFirstName},</p>
<p>Your payment of ${paymentData.amount} could not be processed. Please update your payment method to avoid service interruption.</p>
<p><a href="${paymentData.retryUrl}" style="background: #dc2626; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px;">Update Payment Method</a></p>
`,
priority: 4 // Urgent
})
// Send SMS jika punya phone number
if (paymentData.userPhone) {
await SMSService.sendPaymentFailed(paymentData.userPhone, {
customerName: paymentData.userFirstName,
amount: paymentData.amount,
retryUrl: paymentData.retryUrl
})
}
// Create urgent in-app notification
await this.createCompanyNotification({
companyId,
title: '🚨 Payment Failed',
message: 'Your payment could not be processed. Update your payment method to avoid service interruption.',
type: 'error',
category: 'payment',
data: {
retry_url: paymentData.retryUrl
}
})
console.log(`Payment failed notification sent to ${paymentData.userEmail}`)
} catch (error) {
console.error('Failed to send payment failed notification:', error)
}
}
// Helper method untuk create in-app notification
private static async createNotification(data: {
userId: string
title: string
message: string
type: 'info' | 'success' | 'warning' | 'error'
category: 'order' | 'payment' | 'system' | 'marketing'
data?: Record<string, any>
}) {
try {
const { error } = await this.supabase
.from('notifications')
.insert({
user_id: data.userId,
title: data.title,
message: data.message,
type: data.type,
category: data.category,
data: data.data || {}
})
if (error) throw error
} catch (error) {
console.error('Failed to create notification:', error)
}
}
// Helper method untuk create company-wide notification
private static async createCompanyNotification(data: {
companyId: string
title: string
message: string
type: 'info' | 'success' | 'warning' | 'error'
category: 'order' | 'payment' | 'system' | 'marketing'
data?: Record<string, any>
}) {
try {
const { error } = await this.supabase
.from('notifications')
.insert({
company_id: data.companyId,
title: data.title,
message: data.message,
type: data.type,
category: data.category,
data: data.data || {}
})
if (error) throw error
} catch (error) {
console.error('Failed to create company notification:', error)
}
}
}
API Route untuk Manual Communication
Mari kita buat API routes untuk manually trigger communications:
// app/api/communications/send/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { EmailService } from '@/lib/email/queue'
import { SMSService } from '@/lib/sms/twilio'
import { createRouteHandlerSupabaseClient } from '@/lib/supabase/server'
import { cookies } from 'next/headers'
export async function POST(req: NextRequest) {
try {
const { type, recipients, template, data } = await req.json()
const supabase = createRouteHandlerSupabaseClient({ cookies })
const { data: { session } } = await supabase.auth.getSession()
if (!session) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
)
}
// Get user profile untuk authorization
const { data: profile } = await supabase
.from('profiles')
.select('role, company_id')
.eq('id', session.user.id)
.single()
if (!profile || !['admin', 'owner', 'manager'].includes(profile.role)) {
return NextResponse.json(
{ error: 'Insufficient permissions' },
{ status: 403 }
)
}
const results = []
for (const recipient of recipients) {
try {
switch (type) {
case 'email':
const emailResult = await sendEmailByTemplate(template, recipient, data)
results.push({ recipient: recipient.email, type: 'email', ...emailResult })
break
case 'sms':
const smsResult = await sendSMSByTemplate(template, recipient, data)
results.push({ recipient: recipient.phone, type: 'sms', ...smsResult })
break
case 'whatsapp':
const whatsappResult = await sendWhatsAppByTemplate(template, recipient, data)
results.push({ recipient: recipient.phone, type: 'whatsapp', ...whatsappResult })
break
default:
results.push({
recipient: recipient.email || recipient.phone,
type,
success: false,
error: 'Unknown communication type'
})
}
} catch (error: any) {
results.push({
recipient: recipient.email || recipient.phone,
type,
success: false,
error: error.message
})
}
}
return NextResponse.json({
success: true,
results,
total: recipients.length,
successful: results.filter(r => r.success).length,
failed: results.filter(r => !r.success).length
})
} catch (error: any) {
console.error('Communication send error:', error)
return NextResponse.json(
{ error: error.message },
{ status: 500 }
)
}
}
async function sendEmailByTemplate(template: string, recipient: any, data: any) {
switch (template) {
case 'order_notification':
return await EmailService.sendOrderNotification(recipient.email, {
customerName: recipient.name,
...data
})
case 'payment_reminder':
return await queueEmail({
to: recipient.email,
subject: 'Payment Reminder',
htmlContent: `
<h2>Payment Reminder</h2>
<p>Hi ${recipient.name},</p>
<p>This is a friendly reminder about your upcoming payment of ${data.amount}.</p>
<p>Due date: ${data.dueDate}</p>
`,
priority: 2
})
default:
throw new Error(`Unknown email template: ${template}`)
}
}
async function sendSMSByTemplate(template: string, recipient: any, data: any) {
switch (template) {
case 'order_ready':
return await SMSService.sendOrderReady(recipient.phone, {
customerName: recipient.name,
orderNumber: data.orderNumber,
laundryName: data.laundryName,
laundryAddress: data.laundryAddress
})
case 'payment_reminder':
const message = `Hi ${recipient.name}, your payment of ${data.amount} is due on ${data.dueDate}. Please pay to avoid service interruption.`
return await sendSMS({ to: recipient.phone, message })
default:
throw new Error(`Unknown SMS template: ${template}`)
}
}
async function sendWhatsAppByTemplate(template: string, recipient: any, data: any) {
switch (template) {
case 'order_ready':
return await SMSService.sendWhatsAppOrderReady(recipient.phone, {
customerName: recipient.name,
orderNumber: data.orderNumber,
laundryName: data.laundryName,
trackingUrl: data.trackingUrl
})
case 'payment_reminder':
const message = `🔔 Hi ${recipient.name}!\\n\\nPayment reminder: ${data.amount} is due on ${data.dueDate}.\\n\\nPay now: ${data.paymentUrl}\\n\\nThank you! 😊`
return await sendWhatsApp({ to: recipient.phone, message })
default:
throw new Error(`Unknown WhatsApp template: ${template}`)
}
}
Communication Analytics dan Tracking
Mari kita implement analytics untuk track communication performance:
-- Create communication_logs table untuk tracking
CREATE TABLE communication_logs (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
company_id UUID REFERENCES companies(id) ON DELETE CASCADE,
user_id UUID REFERENCES profiles(id) ON DELETE SET NULL,
customer_id UUID REFERENCES customers(id) ON DELETE SET NULL,
type TEXT NOT NULL CHECK (type IN ('email', 'sms', 'whatsapp', 'push', 'in_app')),
template_name TEXT,
recipient TEXT NOT NULL,
subject TEXT,
content TEXT,
status TEXT NOT NULL CHECK (status IN ('sent', 'delivered', 'opened', 'clicked', 'failed', 'bounced')),
external_id TEXT, -- ID dari service external (Resend, Twilio, etc)
metadata JSONB DEFAULT '{}',
cost_cents INTEGER DEFAULT 0, -- Cost dalam cents
sent_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
delivered_at TIMESTAMP WITH TIME ZONE,
opened_at TIMESTAMP WITH TIME ZONE,
clicked_at TIMESTAMP WITH TIME ZONE,
failed_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes untuk analytics queries
CREATE INDEX idx_communication_logs_company_id ON communication_logs(company_id);
CREATE INDEX idx_communication_logs_type ON communication_logs(type);
CREATE INDEX idx_communication_logs_status ON communication_logs(status);
CREATE INDEX idx_communication_logs_sent_at ON communication_logs(sent_at);
CREATE INDEX idx_communication_logs_template_name ON communication_logs(template_name);
-- Function untuk calculate communication metrics
CREATE OR REPLACE FUNCTION get_communication_metrics(
p_company_id UUID,
p_start_date TIMESTAMP WITH TIME ZONE DEFAULT NOW() - INTERVAL '30 days',
p_end_date TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
RETURNS JSON AS $
DECLARE
result JSON;
BEGIN
SELECT json_build_object(
'total_sent', (
SELECT COUNT(*) FROM communication_logs
WHERE company_id = p_company_id
AND sent_at BETWEEN p_start_date AND p_end_date
),
'by_type', (
SELECT json_object_agg(type, count)
FROM (
SELECT type, COUNT(*) as count
FROM communication_logs
WHERE company_id = p_company_id
AND sent_at BETWEEN p_start_date AND p_end_date
GROUP BY type
) t
),
'delivery_rate', (
SELECT CASE
WHEN COUNT(*) = 0 THEN 0
ELSE ROUND(
COUNT(*) FILTER (WHERE status IN ('delivered', 'opened', 'clicked')) * 100.0 / COUNT(*),
2
)
END
FROM communication_logs
WHERE company_id = p_company_id
AND sent_at BETWEEN p_start_date AND p_end_date
),
'open_rate', (
SELECT CASE
WHEN COUNT(*) = 0 THEN 0
ELSE ROUND(
COUNT(*) FILTER (WHERE status IN ('opened', 'clicked')) * 100.0 / COUNT(*),
2
)
END
FROM communication_logs
WHERE company_id = p_company_id
AND type = 'email'
AND sent_at BETWEEN p_start_date AND p_end_date
),
'total_cost', (
SELECT COALESCE(SUM(cost_cents), 0) / 100.0
FROM communication_logs
WHERE company_id = p_company_id
AND sent_at BETWEEN p_start_date AND p_end_date
)
) INTO result;
RETURN result;
END;
$ LANGUAGE plpgsql;
// lib/communication/analytics.ts
import { createRouteHandlerSupabaseClient } from '@/lib/supabase/server'
import { cookies } from 'next/headers'
export interface CommunicationMetrics {
totalSent: number
byType: Record<string, number>
deliveryRate: number
openRate: number
totalCost: number
trends: Array<{
date: string
sent: number
delivered: number
opened: number
}>
}
export class CommunicationAnalytics {
private static supabase = createRouteHandlerSupabaseClient({ cookies })
static async getMetrics(
companyId: string,
startDate?: Date,
endDate?: Date
): Promise<CommunicationMetrics> {
try {
const start = startDate || new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
const end = endDate || new Date()
// Get overall metrics
const { data: metricsData, error } = await this.supabase
.rpc('get_communication_metrics', {
p_company_id: companyId,
p_start_date: start.toISOString(),
p_end_date: end.toISOString()
})
if (error) throw error
// Get daily trends
const { data: trendsData, error: trendsError } = await this.supabase
.from('communication_logs')
.select('sent_at, status')
.eq('company_id', companyId)
.gte('sent_at', start.toISOString())
.lte('sent_at', end.toISOString())
.order('sent_at')
if (trendsError) throw trendsError
// Process trends data
const trends = this.processTrendsData(trendsData || [])
return {
totalSent: metricsData.total_sent,
byType: metricsData.by_type,
deliveryRate: metricsData.delivery_rate,
openRate: metricsData.open_rate,
totalCost: metricsData.total_cost,
trends
}
} catch (error) {
console.error('Failed to get communication metrics:', error)
throw error
}
}
static async logCommunication(data: {
companyId: string
userId?: string
customerId?: string
type: 'email' | 'sms' | 'whatsapp' | 'push' | 'in_app'
templateName?: string
recipient: string
subject?: string
content?: string
status: 'sent' | 'failed'
externalId?: string
metadata?: Record<string, any>
costCents?: number
}) {
try {
const { error } = await this.supabase
.from('communication_logs')
.insert({
company_id: data.companyId,
user_id: data.userId,
customer_id: data.customerId,
type: data.type,
template_name: data.templateName,
recipient: data.recipient,
subject: data.subject,
content: data.content,
status: data.status,
external_id: data.externalId,
metadata: data.metadata || {},
cost_cents: data.costCents || 0
})
if (error) throw error
} catch (error) {
console.error('Failed to log communication:', error)
}
}
static async updateCommunicationStatus(
externalId: string,
status: 'delivered' | 'opened' | 'clicked' | 'bounced',
timestamp?: Date
) {
try {
const updateData: any = { status }
switch (status) {
case 'delivered':
updateData.delivered_at = (timestamp || new Date()).toISOString()
break
case 'opened':
updateData.opened_at = (timestamp || new Date()).toISOString()
break
case 'clicked':
updateData.clicked_at = (timestamp || new Date()).toISOString()
break
case 'bounced':
updateData.failed_at = (timestamp || new Date()).toISOString()
break
}
const { error } = await this.supabase
.from('communication_logs')
.update(updateData)
.eq('external_id', externalId)
if (error) throw error
} catch (error) {
console.error('Failed to update communication status:', error)
}
}
private static processTrendsData(data: Array<{ sent_at: string; status: string }>) {
const dailyData: Record<string, { sent: number; delivered: number; opened: number }> = {}
data.forEach(item => {
const date = new Date(item.sent_at).toISOString().split('T')[0]
if (!dailyData[date]) {
dailyData[date] = { sent: 0, delivered: 0, opened: 0 }
}
dailyData[date].sent++
if (['delivered', 'opened', 'clicked'].includes(item.status)) {
dailyData[date].delivered++
}
if (['opened', 'clicked'].includes(item.status)) {
dailyData[date].opened++
}
})
return Object.entries(dailyData)
.map(([date, metrics]) => ({ date, ...metrics }))
.sort((a, b) => a.date.localeCompare(b.date))
}
}
Webhook Handlers untuk Communication Status Updates
Mari kita buat webhook handlers untuk update status dari email dan SMS providers:
// app/api/webhooks/resend/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { headers } from 'next/headers'
import { CommunicationAnalytics } from '@/lib/communication/analytics'
export async function POST(req: NextRequest) {
try {
const body = await req.text()
const signature = headers().get('resend-signature')
// Verify webhook signature (implement sesuai Resend docs)
if (!verifyResendSignature(body, signature)) {
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 400 }
)
}
const event = JSON.parse(body)
switch (event.type) {
case 'email.delivered':
await CommunicationAnalytics.updateCommunicationStatus(
event.data.email_id,
'delivered',
new Date(event.created_at)
)
break
case 'email.opened':
await CommunicationAnalytics.updateCommunicationStatus(
event.data.email_id,
'opened',
new Date(event.created_at)
)
break
case 'email.clicked':
await CommunicationAnalytics.updateCommunicationStatus(
event.data.email_id,
'clicked',
new Date(event.created_at)
)
break
case 'email.bounced':
await CommunicationAnalytics.updateCommunicationStatus(
event.data.email_id,
'bounced',
new Date(event.created_at)
)
break
default:
console.log(`Unhandled Resend event: ${event.type}`)
}
return NextResponse.json({ received: true })
} catch (error) {
console.error('Resend webhook error:', error)
return NextResponse.json(
{ error: 'Webhook processing failed' },
{ status: 500 }
)
}
}
function verifyResendSignature(body: string, signature: string | null): boolean {
// Implement signature verification sesuai Resend documentation
return true // Placeholder
}
// app/api/webhooks/twilio/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { CommunicationAnalytics } from '@/lib/communication/analytics'
export async function POST(req: NextRequest) {
try {
const formData = await req.formData()
const messageSid = formData.get('MessageSid') as string
const messageStatus = formData.get('MessageStatus') as string
const timestamp = new Date()
// Update status berdasarkan Twilio message status
let status: 'delivered' | 'bounced' | undefined
switch (messageStatus) {
case 'delivered':
status = 'delivered'
break
case 'failed':
case 'undelivered':
status = 'bounced'
break
default:
// Other statuses like 'sent', 'queued' don't need action
break
}
if (status && messageSid) {
await CommunicationAnalytics.updateCommunicationStatus(
messageSid,
status,
timestamp
)
}
return NextResponse.json({ received: true })
} catch (error) {
console.error('Twilio webhook error:', error)
return NextResponse.json(
{ error: 'Webhook processing failed' },
{ status: 500 }
)
}
}
Communication Dashboard Component
Mari kita buat dashboard untuk monitor communication performance:
// components/communication/CommunicationDashboard.tsx
'use client'
import { useState, useEffect } from 'react'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Mail,
MessageSquare,
Phone,
Bell,
TrendingUp,
Send,
Eye,
MousePointer
} from 'lucide-react'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import { CommunicationMetrics } from '@/lib/communication/analytics'
export function CommunicationDashboard() {
const [metrics, setMetrics] = useState<CommunicationMetrics | null>(null)
const [loading, setLoading] = useState(true)
const [timeRange, setTimeRange] = useState('30d')
useEffect(() => {
fetchMetrics()
}, [timeRange])
const fetchMetrics = async () => {
try {
setLoading(true)
const response = await fetch(`/api/communication/metrics?range=${timeRange}`)
const data = await response.json()
setMetrics(data)
} catch (error) {
console.error('Failed to fetch communication metrics:', error)
} finally {
setLoading(false)
}
}
if (loading) {
return (
<div className="space-y-6">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-4 bg-gray-200 rounded w-1/4"></div>
<div className="h-8 bg-gray-200 rounded w-1/2"></div>
</div>
</CardContent>
</Card>
))}
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Communication Analytics</h1>
<p className="text-muted-foreground">Monitor your communication performance and engagement</p>
</div>
<div className="flex items-center gap-2">
<Button
variant={timeRange === '7d' ? 'default' : 'outline'}
size="sm"
onClick={() => setTimeRange('7d')}
>
7 Days
</Button>
<Button
variant={timeRange === '30d' ? 'default' : 'outline'}
size="sm"
onClick={() => setTimeRange('30d')}
>
30 Days
</Button>
<Button
variant={timeRange === '90d' ? 'default' : 'outline'}
size="sm"
onClick={() => setTimeRange('90d')}
>
90 Days
</Button>
</div>
</div>
{/* Overview Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Sent</CardTitle>
<Send className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{metrics?.totalSent.toLocaleString()}
</div>
<p className="text-xs text-muted-foreground">
All communication channels
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Delivery Rate</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{metrics?.deliveryRate.toFixed(1)}%
</div>
<p className="text-xs text-muted-foreground">
Successfully delivered
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Open Rate</CardTitle>
<Eye className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{metrics?.openRate.toFixed(1)}%
</div>
<p className="text-xs text-muted-foreground">
Email opens
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Cost</CardTitle>
<Badge variant="outline">IDR</Badge>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
Rp {metrics?.totalCost.toLocaleString()}
</div>
<p className="text-xs text-muted-foreground">
Communication costs
</p>
</CardContent>
</Card>
</div>
{/* Communication by Type */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle>Communication by Type</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{Object.entries(metrics?.byType || {}).map(([type, count]) => {
const getIcon = (type: string) => {
switch (type) {
case 'email': return <Mail className="h-4 w-4" />
case 'sms': return <MessageSquare className="h-4 w-4" />
case 'whatsapp': return <Phone className="h-4 w-4" />
case 'in_app': return <Bell className="h-4 w-4" />
default: return <Send className="h-4 w-4" />
}
}
const percentage = metrics?.totalSent ? (count / metrics.totalSent * 100).toFixed(1) : 0
return (
<div key={type} className="flex items-center justify-between">
<div className="flex items-center gap-2">
{getIcon(type)}
<span className="font-medium capitalize">{type}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{percentage}%</span>
<Badge variant="secondary">{count.toLocaleString()}</Badge>
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
{/* Daily Trends */}
<Card>
<CardHeader>
<CardTitle>Daily Trends</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={metrics?.trends || []}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="date"
tick={{ fontSize: 12 }}
tickFormatter={(value) => new Date(value).toLocaleDateString('id-ID', { month: 'short', day: 'numeric' })}
/>
<YAxis tick={{ fontSize: 12 }} />
<Tooltip
labelFormatter={(value) => new Date(value).toLocaleDateString('id-ID')}
/>
<Line
type="monotone"
dataKey="sent"
stroke="#8884d8"
strokeWidth={2}
name="Sent"
/>
<Line
type="monotone"
dataKey="delivered"
stroke="#82ca9d"
strokeWidth={2}
name="Delivered"
/>
<Line
type="monotone"
dataKey="opened"
stroke="#ffc658"
strokeWidth={2}
name="Opened"
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
{/* Recent Communications */}
<Card>
<CardHeader>
<CardTitle>Recent Communications</CardTitle>
</CardHeader>
<CardContent>
<RecentCommunicationsList />
</CardContent>
</Card>
</div>
)
}
function RecentCommunicationsList() {
const [communications, setCommunications] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchRecentCommunications()
}, [])
const fetchRecentCommunications = async () => {
try {
const response = await fetch('/api/communication/recent')
const data = await response.json()
setCommunications(data)
} catch (error) {
console.error('Failed to fetch recent communications:', error)
} finally {
setLoading(false)
}
}
if (loading) {
return (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="animate-pulse flex items-center space-x-4">
<div className="rounded-full bg-gray-200 h-10 w-10"></div>
<div className="flex-1 space-y-2">
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
</div>
</div>
))}
</div>
)
}
return (
<div className="space-y-4">
{communications.map((comm: any) => (
<div key={comm.id} className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-full ${
comm.type === 'email' ? 'bg-blue-100 text-blue-600' :
comm.type === 'sms' ? 'bg-green-100 text-green-600' :
comm.type === 'whatsapp' ? 'bg-emerald-100 text-emerald-600' :
'bg-gray-100 text-gray-600'
}`}>
{comm.type === 'email' && <Mail className="h-4 w-4" />}
{comm.type === 'sms' && <MessageSquare className="h-4 w-4" />}
{comm.type === 'whatsapp' && <Phone className="h-4 w-4" />}
{comm.type === 'in_app' && <Bell className="h-4 w-4" />}
</div>
<div>
<p className="font-medium">{comm.subject || comm.template_name}</p>
<p className="text-sm text-muted-foreground">
To: {comm.recipient} • {new Date(comm.sent_at).toLocaleString()}
</p>
</div>
</div>
<Badge variant={
comm.status === 'sent' ? 'default' :
comm.status === 'delivered' ? 'secondary' :
comm.status === 'opened' ? 'outline' :
comm.status === 'failed' ? 'destructive' :
'secondary'
}>
{comm.status}
</Badge>
</div>
))}
{communications.length === 0 && (
<p className="text-center text-muted-foreground py-8">
No recent communications
</p>
)}
</div>
)
}
Communication Settings dan Preferences
Mari kita buat settings untuk user preferences:
// components/communication/CommunicationSettings.tsx
'use client'
import { useState, useEffect } from 'react'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { toast } from 'sonner'
interface NotificationSettings {
email_notifications: boolean
sms_notifications: boolean
whatsapp_notifications: boolean
push_notifications: boolean
order_updates: boolean
payment_reminders: boolean
marketing_emails: boolean
system_alerts: boolean
}
export function CommunicationSettings() {
const [settings, setSettings] = useState<NotificationSettings>({
email_notifications: true,
sms_notifications: true,
whatsapp_notifications: true,
push_notifications: true,
order_updates: true,
payment_reminders: true,
marketing_emails: false,
system_alerts: true,
})
const [loading, setLoading] = useState(false)
const handleSettingChange = (key: keyof NotificationSettings, value: boolean) => {
setSettings(prev => ({ ...prev, [key]: value }))
}
const handleSave = async () => {
setLoading(true)
try {
const response = await fetch('/api/user/notification-settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings)
})
if (!response.ok) throw new Error('Failed to save settings')
toast.success('Notification settings saved successfully')
} catch (error) {
toast.error('Failed to save settings')
} finally {
setLoading(false)
}
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Notification Preferences</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Communication Channels */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Communication Channels</h3>
<div className="flex items-center justify-between">
<div>
<Label>Email Notifications</Label>
<p className="text-sm text-muted-foreground">Receive notifications via email</p>
</div>
<Switch
checked={settings.email_notifications}
onCheckedChange={(checked) => handleSettingChange('email_notifications', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>SMS Notifications</Label>
<p className="text-sm text-muted-foreground">Receive important updates via SMS</p>
</div>
<Switch
checked={settings.sms_notifications}
onCheckedChange={(checked) => handleSettingChange('sms_notifications', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>WhatsApp Notifications</Label>
<p className="text-sm text-muted-foreground">Receive updates via WhatsApp</p>
</div>
<Switch
checked={settings.whatsapp_notifications}
onCheckedChange={(checked) => handleSettingChange('whatsapp_notifications', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Push Notifications</Label>
<p className="text-sm text-muted-foreground">Browser and mobile push notifications</p>
</div>
<Switch
checked={settings.push_notifications}
onCheckedChange={(checked) => handleSettingChange('push_notifications', checked)}
/>
</div>
</div>
{/* Notification Types */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Notification Types</h3>
<div className="flex items-center justify-between">
<div>
<Label>Order Updates</Label>
<p className="text-sm text-muted-foreground">Status changes and completion notifications</p>
</div>
<Switch
checked={settings.order_updates}
onCheckedChange={(checked) => handleSettingChange('order_updates', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Payment Reminders</Label>
<p className="text-sm text-muted-foreground">Billing and payment notifications</p>
</div>
<Switch
checked={settings.payment_reminders}
onCheckedChange={(checked) => handleSettingChange('payment_reminders', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Marketing Emails</Label>
<p className="text-sm text-muted-foreground">Product updates and promotional content</p>
</div>
<Switch
checked={settings.marketing_emails}
onCheckedChange={(checked) => handleSettingChange('marketing_emails', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>System Alerts</Label>
<p className="text-sm text-muted-foreground">Important system and security alerts</p>
</div>
<Switch
checked={settings.system_alerts}
onCheckedChange={(checked) => handleSettingChange('system_alerts', checked)}
/>
</div>
</div>
<Button onClick={handleSave} disabled={loading} className="w-full">
{loading ? 'Saving...' : 'Save Preferences'}
</Button>
</CardContent>
</Card>
{/* Email Template Customization */}
<Card>
<CardHeader>
<CardTitle>Email Template Customization</CardTitle>
</CardHeader>
<CardContent>
<EmailTemplateCustomizer />
</CardContent>
</Card>
</div>
)
}
function EmailTemplateCustomizer() {
const [selectedTemplate, setSelectedTemplate] = useState('welcome')
const [customization, setCustomization] = useState({
logo_url: '',
primary_color: '#000000',
company_name: '',
footer_text: '',
})
const templates = [
{ value: 'welcome', label: 'Welcome Email' },
{ value: 'order_notification', label: 'Order Notifications' },
{ value: 'payment_confirmation', label: 'Payment Confirmations' },
{ value: 'payment_reminder', label: 'Payment Reminders' },
]
return (
<div className="space-y-4">
<div>
<Label>Template</Label>
<Select value={selectedTemplate} onValueChange={setSelectedTemplate}>
<SelectTrigger>
<SelectValue placeholder="Select a template" />
</SelectTrigger>
<SelectContent>
{templates.map((template) => (
<SelectItem key={template.value} value={template.value}>
{template.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Logo URL</Label>
<Input
value={customization.logo_url}
onChange={(e) => setCustomization(prev => ({ ...prev, logo_url: e.target.value }))}
placeholder="<https://your-domain.com/logo.png>"
/>
</div>
<div>
<Label>Primary Color</Label>
<Input
type="color"
value={customization.primary_color}
onChange={(e) => setCustomization(prev => ({ ...prev, primary_color: e.target.value }))}
/>
</div>
</div>
<div>
<Label>Company Name</Label>
<Input
value={customization.company_name}
onChange={(e) => setCustomization(prev => ({ ...prev, company_name: e.target.value }))}
placeholder="Your Company Name"
/>
</div>
<div>
<Label>Footer Text</Label>
<Textarea
value={customization.footer_text}
onChange={(e) => setCustomization(prev => ({ ...prev, footer_text: e.target.value }))}
placeholder="Custom footer text for your emails"
rows={3}
/>
</div>
<div className="flex gap-2">
<Button variant="outline">Preview Template</Button>
<Button>Save Customization</Button>
</div>
</div>
)
}
Roadmap Menuju Developer yang Exceptional
Congratulations! Kalian udah berhasil membangun SaaS Laundry Management System yang comprehensive - dari authentication, real-time features, payment integration, sampai communication system. Ini bukan akhir perjalanan, tapi awal dari adventure yang lebih besar.
Langkah Selanjutnya
Enterprise-Level Development
- AI Analytics: Machine learning untuk predictive analytics dan business intelligence
- IoT Integration: Smart laundry machines dengan real-time monitoring
- Global Expansion: Multi-currency, localization, dan compliance international
- Advanced Security: Zero-trust architecture dan enterprise-grade protection
Teknologi yang Perlu Dipelajari
- Blockchain & Web3 untuk automated business agreements
- Edge Computing untuk processing yang lebih cepat
- Micro-frontends dan advanced frontend architectures
- Kubernetes dan modern DevOps practices
Mengapa BuildWithAngga?
Untuk mencapai level exceptional developer, kalian butuh guidance yang tepat:
- Lifetime access ke learning materials terbaru
- 1-on-1 mentoring dengan industry experts
- Real-world projects yang applicable di industri
- Job placement assistance dengan network perusahaan terpercaya
- Community support untuk networking dan collaboration
- Industry best practices dan clean architecture
- Career advancement guidance dari developer sampai tech lead
- Technical interview coaching untuk increase success rate
Pesan Akhir
Technology is about solving real-world problems dan creating value. Aplikasi yang kalian build bisa transform bisnis tradisional menjadi operation yang modern dan efficient.
Every expert was once a beginner. Yang membedakan adalah commitment untuk never stop learning dan always push themselves to grow.
Your journey as an exceptional developer starts now. Dengan foundation yang solid dan support system yang tepat, kalian bisa achieve anything dalam career kalian.
Build amazing things. Solve real problems. Change the world through code.