create-nextjs-stack 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +60 -0
- package/bin/cli.js +187 -0
- package/package.json +48 -0
- package/templates/admin/.env.example +11 -0
- package/templates/admin/README.md +82 -0
- package/templates/admin/app/(auth)/login/page.tsx +84 -0
- package/templates/admin/app/(dashboard)/[resource]/[id]/page.tsx +45 -0
- package/templates/admin/app/(dashboard)/[resource]/new/page.tsx +32 -0
- package/templates/admin/app/(dashboard)/[resource]/page.tsx +131 -0
- package/templates/admin/app/(dashboard)/categories/[id]/page.tsx +22 -0
- package/templates/admin/app/(dashboard)/categories/new/page.tsx +5 -0
- package/templates/admin/app/(dashboard)/categories/page.tsx +33 -0
- package/templates/admin/app/(dashboard)/clients/[id]/page.tsx +22 -0
- package/templates/admin/app/(dashboard)/clients/new/page.tsx +5 -0
- package/templates/admin/app/(dashboard)/clients/page.tsx +33 -0
- package/templates/admin/app/(dashboard)/dashboard/page.tsx +45 -0
- package/templates/admin/app/(dashboard)/layout.tsx +13 -0
- package/templates/admin/app/(dashboard)/products/[id]/page.tsx +22 -0
- package/templates/admin/app/(dashboard)/products/new/page.tsx +5 -0
- package/templates/admin/app/(dashboard)/products/page.tsx +33 -0
- package/templates/admin/app/(dashboard)/projects/[id]/page.tsx +22 -0
- package/templates/admin/app/(dashboard)/projects/new/page.tsx +5 -0
- package/templates/admin/app/(dashboard)/projects/page.tsx +33 -0
- package/templates/admin/app/(dashboard)/users/[id]/page.tsx +22 -0
- package/templates/admin/app/(dashboard)/users/new/page.tsx +5 -0
- package/templates/admin/app/(dashboard)/users/page.tsx +33 -0
- package/templates/admin/app/actions/resources.ts +46 -0
- package/templates/admin/app/actions/upload.ts +58 -0
- package/templates/admin/app/favicon.ico +0 -0
- package/templates/admin/app/globals.css +23 -0
- package/templates/admin/app/layout.tsx +23 -0
- package/templates/admin/app/page.tsx +5 -0
- package/templates/admin/components/admin/AdminLayoutClient.tsx +22 -0
- package/templates/admin/components/admin/DeleteModal.tsx +90 -0
- package/templates/admin/components/admin/FormLayout.tsx +113 -0
- package/templates/admin/components/admin/ImageUpload.tsx +137 -0
- package/templates/admin/components/admin/ResourceFormClient.tsx +62 -0
- package/templates/admin/components/admin/Sidebar.tsx +74 -0
- package/templates/admin/components/admin/SubmitButton.tsx +34 -0
- package/templates/admin/components/admin/ToastProvider.tsx +8 -0
- package/templates/admin/components/categories/CategoryForm.tsx +24 -0
- package/templates/admin/components/categories/CategoryList.tsx +113 -0
- package/templates/admin/components/clients/ClientForm.tsx +24 -0
- package/templates/admin/components/clients/ClientList.tsx +113 -0
- package/templates/admin/components/products/ProductForm.tsx +24 -0
- package/templates/admin/components/products/ProductList.tsx +117 -0
- package/templates/admin/components/projects/ProjectForm.tsx +24 -0
- package/templates/admin/components/projects/ProjectList.tsx +121 -0
- package/templates/admin/components/users/UserForm.tsx +39 -0
- package/templates/admin/components/users/UserList.tsx +101 -0
- package/templates/admin/config/resources.ts +123 -0
- package/templates/admin/eslint.config.mjs +18 -0
- package/templates/admin/hooks/useResource.ts +86 -0
- package/templates/admin/lib/services/base.service.ts +106 -0
- package/templates/admin/lib/services/categories.service.ts +7 -0
- package/templates/admin/lib/services/clients.service.ts +7 -0
- package/templates/admin/lib/services/index.ts +27 -0
- package/templates/admin/lib/services/products.service.ts +9 -0
- package/templates/admin/lib/services/projects.service.ts +22 -0
- package/templates/admin/lib/services/resource.service.ts +26 -0
- package/templates/admin/lib/services/users.service.ts +9 -0
- package/templates/admin/lib/supabase/client.ts +9 -0
- package/templates/admin/lib/supabase/middleware.ts +57 -0
- package/templates/admin/lib/supabase/server.ts +29 -0
- package/templates/admin/middleware.ts +15 -0
- package/templates/admin/next.config.ts +10 -0
- package/templates/admin/package-lock.json +6768 -0
- package/templates/admin/package.json +33 -0
- package/templates/admin/postcss.config.mjs +7 -0
- package/templates/admin/public/file.svg +1 -0
- package/templates/admin/public/globe.svg +1 -0
- package/templates/admin/public/next.svg +1 -0
- package/templates/admin/public/vercel.svg +1 -0
- package/templates/admin/public/window.svg +1 -0
- package/templates/admin/supabase_mock_data.sql +57 -0
- package/templates/admin/supabase_schema.sql +93 -0
- package/templates/admin/tsconfig.json +34 -0
- package/templates/web/.env.example +21 -0
- package/templates/web/README.md +129 -0
- package/templates/web/components.json +22 -0
- package/templates/web/eslint.config.mjs +25 -0
- package/templates/web/next.config.ts +25 -0
- package/templates/web/package-lock.json +6778 -0
- package/templates/web/package.json +45 -0
- package/templates/web/postcss.config.mjs +5 -0
- package/templates/web/src/app/api/contact/route.ts +181 -0
- package/templates/web/src/app/api/revalidate/route.ts +95 -0
- package/templates/web/src/app/error.tsx +28 -0
- package/templates/web/src/app/globals.css +838 -0
- package/templates/web/src/app/layout.tsx +126 -0
- package/templates/web/src/app/loading.tsx +60 -0
- package/templates/web/src/app/not-found.tsx +68 -0
- package/templates/web/src/app/page.tsx +106 -0
- package/templates/web/src/app/robots.ts +12 -0
- package/templates/web/src/app/sitemap.ts +66 -0
- package/templates/web/src/components/home/StatsGrid.tsx +89 -0
- package/templates/web/src/hooks/useIntersectionObserver.ts +39 -0
- package/templates/web/src/lib/providers/StoreProvider.tsx +12 -0
- package/templates/web/src/lib/seo/index.ts +4 -0
- package/templates/web/src/lib/seo/metadata.ts +103 -0
- package/templates/web/src/lib/seo/seo.config.ts +161 -0
- package/templates/web/src/lib/seo/seo.types.ts +76 -0
- package/templates/web/src/lib/services/categories.service.ts +38 -0
- package/templates/web/src/lib/services/categoryService.ts +251 -0
- package/templates/web/src/lib/services/clientService.ts +132 -0
- package/templates/web/src/lib/services/clients.service.ts +20 -0
- package/templates/web/src/lib/services/productService.ts +261 -0
- package/templates/web/src/lib/services/products.service.ts +38 -0
- package/templates/web/src/lib/services/projectService.ts +234 -0
- package/templates/web/src/lib/services/projects.service.ts +38 -0
- package/templates/web/src/lib/services/users.service.ts +20 -0
- package/templates/web/src/lib/supabase/client.ts +42 -0
- package/templates/web/src/lib/supabase/constants.ts +25 -0
- package/templates/web/src/lib/supabase/server.ts +29 -0
- package/templates/web/src/lib/supabase/types.ts +112 -0
- package/templates/web/src/lib/utils/cache.ts +98 -0
- package/templates/web/src/lib/utils/rate-limiter.ts +102 -0
- package/templates/web/src/store/actions/index.ts +2 -0
- package/templates/web/src/store/index.ts +13 -0
- package/templates/web/src/store/reducers/index.ts +13 -0
- package/templates/web/src/store/types/index.ts +2 -0
- package/templates/web/tsconfig.json +41 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { getBrowserClient } from '@/lib/supabase/client';
|
|
2
|
+
import { TABLE_NAMES, CACHE_TIMES } from '@/lib/supabase/constants';
|
|
3
|
+
import { memoryCache } from '@/lib/utils/cache';
|
|
4
|
+
import { perMinuteLimiter } from '@/lib/utils/rate-limiter';
|
|
5
|
+
import type { Client } from '@/lib/supabase/types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Client Service - Handles all client-related database operations
|
|
9
|
+
* Implements singleton pattern with caching and rate limiting
|
|
10
|
+
*/
|
|
11
|
+
export class ClientService {
|
|
12
|
+
private static instance: ClientService;
|
|
13
|
+
|
|
14
|
+
private constructor() {}
|
|
15
|
+
|
|
16
|
+
static getInstance(): ClientService {
|
|
17
|
+
if (!ClientService.instance) {
|
|
18
|
+
ClientService.instance = new ClientService();
|
|
19
|
+
}
|
|
20
|
+
return ClientService.instance;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get all published clients ordered by sort_order
|
|
25
|
+
*/
|
|
26
|
+
async getAll(): Promise<Client[]> {
|
|
27
|
+
const cacheKey = 'clients:all';
|
|
28
|
+
|
|
29
|
+
const cached = memoryCache.get<Client[]>(cacheKey);
|
|
30
|
+
if (cached) {
|
|
31
|
+
return cached;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const allowed = await perMinuteLimiter.checkLimit('clients:getAll');
|
|
35
|
+
if (!allowed) {
|
|
36
|
+
throw new Error('Rate limit exceeded. Please try again later.');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const supabase = getBrowserClient();
|
|
40
|
+
const { data, error } = await supabase
|
|
41
|
+
.from(TABLE_NAMES.CLIENTS)
|
|
42
|
+
.select('*')
|
|
43
|
+
.eq('published', true)
|
|
44
|
+
.order('sort_order', { ascending: true });
|
|
45
|
+
|
|
46
|
+
if (error) {
|
|
47
|
+
console.error('[ClientService] Error fetching clients:', error);
|
|
48
|
+
throw new Error(`Failed to fetch clients: ${error.message}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
memoryCache.set(cacheKey, data, CACHE_TIMES.CLIENTS);
|
|
52
|
+
return data;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Get client by name
|
|
57
|
+
*/
|
|
58
|
+
async getByName(name: string): Promise<Client | null> {
|
|
59
|
+
const cacheKey = `clients:name:${name}`;
|
|
60
|
+
|
|
61
|
+
const cached = memoryCache.get<Client>(cacheKey);
|
|
62
|
+
if (cached) {
|
|
63
|
+
return cached;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const supabase = getBrowserClient();
|
|
67
|
+
const { data, error } = await supabase
|
|
68
|
+
.from(TABLE_NAMES.CLIENTS)
|
|
69
|
+
.select('*')
|
|
70
|
+
.eq('name', name)
|
|
71
|
+
.eq('published', true)
|
|
72
|
+
.single();
|
|
73
|
+
|
|
74
|
+
if (error) {
|
|
75
|
+
if (error.code === 'PGRST116') {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
console.error('[ClientService] Error fetching client:', error);
|
|
79
|
+
throw new Error(`Failed to fetch client: ${error.message}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
memoryCache.set(cacheKey, data, CACHE_TIMES.CLIENTS);
|
|
83
|
+
return data;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get client by ID
|
|
88
|
+
*/
|
|
89
|
+
async getById(id: string): Promise<Client | null> {
|
|
90
|
+
const cacheKey = `clients:id:${id}`;
|
|
91
|
+
|
|
92
|
+
const cached = memoryCache.get<Client>(cacheKey);
|
|
93
|
+
if (cached) {
|
|
94
|
+
return cached;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const supabase = getBrowserClient();
|
|
98
|
+
const { data, error } = await supabase
|
|
99
|
+
.from(TABLE_NAMES.CLIENTS)
|
|
100
|
+
.select('*')
|
|
101
|
+
.eq('id', id)
|
|
102
|
+
.eq('published', true)
|
|
103
|
+
.single();
|
|
104
|
+
|
|
105
|
+
if (error) {
|
|
106
|
+
if (error.code === 'PGRST116') {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
console.error('[ClientService] Error fetching client by ID:', error);
|
|
110
|
+
throw new Error(`Failed to fetch client: ${error.message}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
memoryCache.set(cacheKey, data, CACHE_TIMES.CLIENTS);
|
|
114
|
+
return data;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Invalidate all client caches
|
|
119
|
+
*/
|
|
120
|
+
invalidateCache(): void {
|
|
121
|
+
memoryCache.invalidate('clients:');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Invalidate specific client cache
|
|
126
|
+
*/
|
|
127
|
+
invalidateClientCache(name: string): void {
|
|
128
|
+
memoryCache.invalidateKey(`clients:name:${name}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export const clientService = ClientService.getInstance();
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { getServerClient } from "@/lib/supabase/server";
|
|
2
|
+
|
|
3
|
+
export class ClientService {
|
|
4
|
+
private static table = "clients";
|
|
5
|
+
|
|
6
|
+
static async getAll() {
|
|
7
|
+
const supabase = await getServerClient();
|
|
8
|
+
const { data, error } = await supabase
|
|
9
|
+
.from(this.table)
|
|
10
|
+
.select("*")
|
|
11
|
+
.order("name");
|
|
12
|
+
|
|
13
|
+
if (error) {
|
|
14
|
+
console.error(`Error fetching ${this.table}:`, error);
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return data;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { getBrowserClient } from '@/lib/supabase/client';
|
|
2
|
+
import { TABLE_NAMES, CACHE_TIMES } from '@/lib/supabase/constants';
|
|
3
|
+
import { memoryCache } from '@/lib/utils/cache';
|
|
4
|
+
import { perMinuteLimiter } from '@/lib/utils/rate-limiter';
|
|
5
|
+
import type { Product, ProductWithCategory } from '@/lib/supabase/types';
|
|
6
|
+
|
|
7
|
+
interface GetProductsOptions {
|
|
8
|
+
limit?: number;
|
|
9
|
+
offset?: number;
|
|
10
|
+
categoryId?: string;
|
|
11
|
+
featured?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Product Service - Handles all product-related database operations
|
|
16
|
+
* Implements singleton pattern with caching and rate limiting
|
|
17
|
+
*/
|
|
18
|
+
export class ProductService {
|
|
19
|
+
private static instance: ProductService;
|
|
20
|
+
|
|
21
|
+
private constructor() {}
|
|
22
|
+
|
|
23
|
+
static getInstance(): ProductService {
|
|
24
|
+
if (!ProductService.instance) {
|
|
25
|
+
ProductService.instance = new ProductService();
|
|
26
|
+
}
|
|
27
|
+
return ProductService.instance;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Get all published products with optional filtering
|
|
32
|
+
*/
|
|
33
|
+
async getAll(options: GetProductsOptions = {}): Promise<Product[]> {
|
|
34
|
+
const { limit = 100, offset = 0, categoryId, featured } = options;
|
|
35
|
+
const cacheKey = `products:all:${limit}:${offset}:${categoryId || 'none'}:${featured || 'all'}`;
|
|
36
|
+
|
|
37
|
+
const cached = memoryCache.get<Product[]>(cacheKey);
|
|
38
|
+
if (cached) {
|
|
39
|
+
return cached;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const allowed = await perMinuteLimiter.checkLimit('products:getAll');
|
|
43
|
+
if (!allowed) {
|
|
44
|
+
throw new Error('Rate limit exceeded. Please try again later.');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const supabase = getBrowserClient();
|
|
48
|
+
let query = supabase
|
|
49
|
+
.from(TABLE_NAMES.PRODUCTS)
|
|
50
|
+
.select('*')
|
|
51
|
+
.eq('published', true)
|
|
52
|
+
.order('created_at', { ascending: false })
|
|
53
|
+
.range(offset, offset + limit - 1);
|
|
54
|
+
|
|
55
|
+
if (categoryId) {
|
|
56
|
+
query = query.eq('category_id', categoryId);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (featured !== undefined) {
|
|
60
|
+
query = query.eq('featured', featured);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const { data, error } = await query;
|
|
64
|
+
|
|
65
|
+
if (error) {
|
|
66
|
+
console.error('[ProductService] Error fetching products:', error);
|
|
67
|
+
throw new Error(`Failed to fetch products: ${error.message}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
memoryCache.set(cacheKey, data, CACHE_TIMES.PRODUCTS);
|
|
71
|
+
return data;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get product by slug with category information
|
|
76
|
+
*/
|
|
77
|
+
async getBySlug(slug: string): Promise<ProductWithCategory | null> {
|
|
78
|
+
const cacheKey = `products:slug:${slug}`;
|
|
79
|
+
|
|
80
|
+
const cached = memoryCache.get<ProductWithCategory>(cacheKey);
|
|
81
|
+
if (cached) {
|
|
82
|
+
return cached;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const allowed = await perMinuteLimiter.checkLimit('products:getBySlug');
|
|
86
|
+
if (!allowed) {
|
|
87
|
+
throw new Error('Rate limit exceeded. Please try again later.');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const supabase = getBrowserClient();
|
|
91
|
+
const { data, error } = await supabase
|
|
92
|
+
.from(TABLE_NAMES.PRODUCTS)
|
|
93
|
+
.select(`
|
|
94
|
+
*,
|
|
95
|
+
category:categories(*)
|
|
96
|
+
`)
|
|
97
|
+
.eq('slug', slug)
|
|
98
|
+
.eq('published', true)
|
|
99
|
+
.single();
|
|
100
|
+
|
|
101
|
+
if (error) {
|
|
102
|
+
if (error.code === 'PGRST116') {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
console.error('[ProductService] Error fetching product:', error);
|
|
106
|
+
throw new Error(`Failed to fetch product: ${error.message}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const productWithCategory = data as unknown as ProductWithCategory;
|
|
110
|
+
memoryCache.set(cacheKey, productWithCategory, CACHE_TIMES.PRODUCTS);
|
|
111
|
+
return productWithCategory;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get featured products
|
|
116
|
+
*/
|
|
117
|
+
async getFeatured(): Promise<Product[]> {
|
|
118
|
+
return this.getAll({ featured: true });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Get products by category
|
|
123
|
+
*/
|
|
124
|
+
async getByCategory(categoryId: string, limit = 100): Promise<Product[]> {
|
|
125
|
+
return this.getAll({ categoryId, limit });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Get related products (same category, excluding current product)
|
|
130
|
+
*/
|
|
131
|
+
async getRelated(productId: string, categoryId: string, limit = 4): Promise<Product[]> {
|
|
132
|
+
const cacheKey = `products:related:${productId}:${limit}`;
|
|
133
|
+
|
|
134
|
+
const cached = memoryCache.get<Product[]>(cacheKey);
|
|
135
|
+
if (cached) {
|
|
136
|
+
return cached;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const supabase = getBrowserClient();
|
|
140
|
+
const { data, error } = await supabase
|
|
141
|
+
.from(TABLE_NAMES.PRODUCTS)
|
|
142
|
+
.select('*')
|
|
143
|
+
.eq('category_id', categoryId)
|
|
144
|
+
.eq('published', true)
|
|
145
|
+
.neq('id', productId)
|
|
146
|
+
.limit(limit);
|
|
147
|
+
|
|
148
|
+
if (error) {
|
|
149
|
+
console.error('[ProductService] Error fetching related products:', error);
|
|
150
|
+
throw new Error(`Failed to fetch related products: ${error.message}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
memoryCache.set(cacheKey, data, CACHE_TIMES.PRODUCTS);
|
|
154
|
+
return data;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Get product by ID
|
|
159
|
+
*/
|
|
160
|
+
async getById(id: string): Promise<Product | null> {
|
|
161
|
+
const cacheKey = `products:id:${id}`;
|
|
162
|
+
|
|
163
|
+
const cached = memoryCache.get<Product>(cacheKey);
|
|
164
|
+
if (cached) {
|
|
165
|
+
return cached;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const supabase = getBrowserClient();
|
|
169
|
+
const { data, error } = await supabase
|
|
170
|
+
.from(TABLE_NAMES.PRODUCTS)
|
|
171
|
+
.select('*')
|
|
172
|
+
.eq('id', id)
|
|
173
|
+
.eq('published', true)
|
|
174
|
+
.single();
|
|
175
|
+
|
|
176
|
+
if (error) {
|
|
177
|
+
if (error.code === 'PGRST116') {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
console.error('[ProductService] Error fetching product by ID:', error);
|
|
181
|
+
throw new Error(`Failed to fetch product: ${error.message}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
memoryCache.set(cacheKey, data, CACHE_TIMES.PRODUCTS);
|
|
185
|
+
return data;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Get multiple products by IDs
|
|
190
|
+
*/
|
|
191
|
+
async getByIds(ids: string[]): Promise<Product[]> {
|
|
192
|
+
if (ids.length === 0) {
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const cacheKey = `products:ids:${ids.sort().join(',')}`;
|
|
197
|
+
|
|
198
|
+
const cached = memoryCache.get<Product[]>(cacheKey);
|
|
199
|
+
if (cached) {
|
|
200
|
+
return cached;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const supabase = getBrowserClient();
|
|
204
|
+
const { data, error } = await supabase
|
|
205
|
+
.from(TABLE_NAMES.PRODUCTS)
|
|
206
|
+
.select('*')
|
|
207
|
+
.in('id', ids)
|
|
208
|
+
.eq('published', true);
|
|
209
|
+
|
|
210
|
+
if (error) {
|
|
211
|
+
console.error('[ProductService] Error fetching products by IDs:', error);
|
|
212
|
+
throw new Error(`Failed to fetch products: ${error.message}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
memoryCache.set(cacheKey, data, CACHE_TIMES.PRODUCTS);
|
|
216
|
+
return data;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Get all product slugs for static generation
|
|
221
|
+
*/
|
|
222
|
+
async getAllSlugs(): Promise<string[]> {
|
|
223
|
+
const cacheKey = 'products:slugs:all';
|
|
224
|
+
|
|
225
|
+
const cached = memoryCache.get<string[]>(cacheKey);
|
|
226
|
+
if (cached) {
|
|
227
|
+
return cached;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const supabase = getBrowserClient();
|
|
231
|
+
const { data, error } = await supabase
|
|
232
|
+
.from(TABLE_NAMES.PRODUCTS)
|
|
233
|
+
.select('slug')
|
|
234
|
+
.eq('published', true);
|
|
235
|
+
|
|
236
|
+
if (error) {
|
|
237
|
+
console.error('[ProductService] Error fetching product slugs:', error);
|
|
238
|
+
throw new Error(`Failed to fetch product slugs: ${error.message}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const slugs = (data as { slug: string }[]).map((item) => item.slug);
|
|
242
|
+
memoryCache.set(cacheKey, slugs, CACHE_TIMES.PRODUCTS);
|
|
243
|
+
return slugs;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Invalidate all product caches
|
|
248
|
+
*/
|
|
249
|
+
invalidateCache(): void {
|
|
250
|
+
memoryCache.invalidate('products:');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Invalidate specific product cache
|
|
255
|
+
*/
|
|
256
|
+
invalidateProductCache(slug: string): void {
|
|
257
|
+
memoryCache.invalidateKey(`products:slug:${slug}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export const productService = ProductService.getInstance();
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { getServerClient } from "@/lib/supabase/server";
|
|
2
|
+
|
|
3
|
+
export class ProductService {
|
|
4
|
+
private static table = "products";
|
|
5
|
+
|
|
6
|
+
static async getAll() {
|
|
7
|
+
const supabase = await getServerClient();
|
|
8
|
+
const { data, error } = await supabase
|
|
9
|
+
.from(this.table)
|
|
10
|
+
.select("*, categories(title, slug)")
|
|
11
|
+
.eq("published", true)
|
|
12
|
+
.order("created_at", { ascending: false });
|
|
13
|
+
|
|
14
|
+
if (error) {
|
|
15
|
+
console.error(`Error fetching ${this.table}:`, error);
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return data;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
static async getBySlug(slug: string) {
|
|
23
|
+
const supabase = await getServerClient();
|
|
24
|
+
const { data, error } = await supabase
|
|
25
|
+
.from(this.table)
|
|
26
|
+
.select("*, categories(title, slug)")
|
|
27
|
+
.eq("slug", slug)
|
|
28
|
+
.eq("published", true)
|
|
29
|
+
.single();
|
|
30
|
+
|
|
31
|
+
if (error) {
|
|
32
|
+
console.error(`Error fetching ${this.table} ${slug}:`, error);
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return data;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { getBrowserClient } from '@/lib/supabase/client';
|
|
2
|
+
import { TABLE_NAMES, CACHE_TIMES } from '@/lib/supabase/constants';
|
|
3
|
+
import { memoryCache } from '@/lib/utils/cache';
|
|
4
|
+
import { perMinuteLimiter } from '@/lib/utils/rate-limiter';
|
|
5
|
+
import type { Project, ProjectWithProducts } from '@/lib/supabase/types';
|
|
6
|
+
import { productService } from './productService';
|
|
7
|
+
|
|
8
|
+
interface GetProjectsOptions {
|
|
9
|
+
limit?: number;
|
|
10
|
+
offset?: number;
|
|
11
|
+
year?: number;
|
|
12
|
+
featured?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Project Service - Handles all project-related database operations
|
|
17
|
+
* Implements singleton pattern with caching and rate limiting
|
|
18
|
+
*/
|
|
19
|
+
export class ProjectService {
|
|
20
|
+
private static instance: ProjectService;
|
|
21
|
+
|
|
22
|
+
private constructor() {}
|
|
23
|
+
|
|
24
|
+
static getInstance(): ProjectService {
|
|
25
|
+
if (!ProjectService.instance) {
|
|
26
|
+
ProjectService.instance = new ProjectService();
|
|
27
|
+
}
|
|
28
|
+
return ProjectService.instance;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get all published projects with optional filtering
|
|
33
|
+
*/
|
|
34
|
+
async getAll(options: GetProjectsOptions = {}): Promise<Project[]> {
|
|
35
|
+
const { limit = 100, offset = 0, year, featured } = options;
|
|
36
|
+
const cacheKey = `projects:all:${limit}:${offset}:${year || 'all'}:${featured || 'all'}`;
|
|
37
|
+
|
|
38
|
+
const cached = memoryCache.get<Project[]>(cacheKey);
|
|
39
|
+
if (cached) {
|
|
40
|
+
return cached;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const allowed = await perMinuteLimiter.checkLimit('projects:getAll');
|
|
44
|
+
if (!allowed) {
|
|
45
|
+
throw new Error('Rate limit exceeded. Please try again later.');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const supabase = getBrowserClient();
|
|
49
|
+
let query = supabase
|
|
50
|
+
.from(TABLE_NAMES.PROJECTS)
|
|
51
|
+
.select('*')
|
|
52
|
+
.eq('published', true)
|
|
53
|
+
.order('year', { ascending: false })
|
|
54
|
+
.order('created_at', { ascending: false })
|
|
55
|
+
.range(offset, offset + limit - 1);
|
|
56
|
+
|
|
57
|
+
if (year !== undefined) {
|
|
58
|
+
query = query.eq('year', year);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (featured !== undefined) {
|
|
62
|
+
query = query.eq('featured', featured);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const { data, error } = await query;
|
|
66
|
+
|
|
67
|
+
if (error) {
|
|
68
|
+
console.error('[ProjectService] Error fetching projects:', error);
|
|
69
|
+
throw new Error(`Failed to fetch projects: ${error.message}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
memoryCache.set(cacheKey, data, CACHE_TIMES.PROJECTS);
|
|
73
|
+
return data as Project[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Get project by slug with related products
|
|
78
|
+
*/
|
|
79
|
+
async getBySlug(slug: string): Promise<ProjectWithProducts | null> {
|
|
80
|
+
const cacheKey = `projects:slug:${slug}`;
|
|
81
|
+
|
|
82
|
+
const cached = memoryCache.get<ProjectWithProducts>(cacheKey);
|
|
83
|
+
if (cached) {
|
|
84
|
+
return cached;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const allowed = await perMinuteLimiter.checkLimit('projects:getBySlug');
|
|
88
|
+
if (!allowed) {
|
|
89
|
+
throw new Error('Rate limit exceeded. Please try again later.');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const supabase = getBrowserClient();
|
|
93
|
+
const { data, error } = await supabase
|
|
94
|
+
.from(TABLE_NAMES.PROJECTS)
|
|
95
|
+
.select('*')
|
|
96
|
+
.eq('slug', slug)
|
|
97
|
+
.eq('published', true)
|
|
98
|
+
.single();
|
|
99
|
+
|
|
100
|
+
if (error) {
|
|
101
|
+
if (error.code === 'PGRST116') {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
console.error('[ProjectService] Error fetching project:', error);
|
|
105
|
+
throw new Error(`Failed to fetch project: ${error.message}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Fetch related products
|
|
109
|
+
let products: import('@/lib/supabase/types').Product[] = [];
|
|
110
|
+
const projectData = data as Project;
|
|
111
|
+
if (projectData.product_ids && projectData.product_ids.length > 0) {
|
|
112
|
+
try {
|
|
113
|
+
products = await productService.getByIds(projectData.product_ids);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.error('[ProjectService] Error fetching related products:', error);
|
|
116
|
+
// Continue without products rather than failing
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const projectWithProducts: ProjectWithProducts = {
|
|
121
|
+
...projectData,
|
|
122
|
+
products,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
memoryCache.set(cacheKey, projectWithProducts, CACHE_TIMES.PROJECTS);
|
|
126
|
+
return projectWithProducts;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Get featured projects
|
|
131
|
+
*/
|
|
132
|
+
async getFeatured(): Promise<Project[]> {
|
|
133
|
+
return this.getAll({ featured: true });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Get projects by year
|
|
138
|
+
*/
|
|
139
|
+
async getByYear(year: number): Promise<Project[]> {
|
|
140
|
+
return this.getAll({ year });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Get project by ID
|
|
145
|
+
*/
|
|
146
|
+
async getById(id: string): Promise<Project | null> {
|
|
147
|
+
const cacheKey = `projects:id:${id}`;
|
|
148
|
+
|
|
149
|
+
const cached = memoryCache.get<Project>(cacheKey);
|
|
150
|
+
if (cached) {
|
|
151
|
+
return cached;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const supabase = getBrowserClient();
|
|
155
|
+
const { data, error } = await supabase
|
|
156
|
+
.from(TABLE_NAMES.PROJECTS)
|
|
157
|
+
.select('*')
|
|
158
|
+
.eq('id', id)
|
|
159
|
+
.eq('published', true)
|
|
160
|
+
.single();
|
|
161
|
+
|
|
162
|
+
if (error) {
|
|
163
|
+
if (error.code === 'PGRST116') {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
console.error('[ProjectService] Error fetching project by ID:', error);
|
|
167
|
+
throw new Error(`Failed to fetch project: ${error.message}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
memoryCache.set(cacheKey, data, CACHE_TIMES.PROJECTS);
|
|
171
|
+
return data;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Get all project slugs for static generation
|
|
176
|
+
*/
|
|
177
|
+
async getAllSlugs(): Promise<string[]> {
|
|
178
|
+
const cacheKey = 'projects:slugs:all';
|
|
179
|
+
|
|
180
|
+
const cached = memoryCache.get<string[]>(cacheKey);
|
|
181
|
+
if (cached) {
|
|
182
|
+
return cached;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const supabase = getBrowserClient();
|
|
186
|
+
const { data, error } = await supabase
|
|
187
|
+
.from(TABLE_NAMES.PROJECTS)
|
|
188
|
+
.select('slug')
|
|
189
|
+
.eq('published', true);
|
|
190
|
+
|
|
191
|
+
if (error) {
|
|
192
|
+
console.error('[ProjectService] Error fetching project slugs:', error);
|
|
193
|
+
throw new Error(`Failed to fetch project slugs: ${error.message}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const slugs = (data as { slug: string }[]).map((item) => item.slug);
|
|
197
|
+
memoryCache.set(cacheKey, slugs, CACHE_TIMES.PROJECTS);
|
|
198
|
+
return slugs;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Get unique years from all projects
|
|
203
|
+
*/
|
|
204
|
+
async getYears(): Promise<number[]> {
|
|
205
|
+
const cacheKey = 'projects:years';
|
|
206
|
+
|
|
207
|
+
const cached = memoryCache.get<number[]>(cacheKey);
|
|
208
|
+
if (cached) {
|
|
209
|
+
return cached;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const projects = await this.getAll();
|
|
213
|
+
const years = [...new Set(projects.map((p) => p.year))].sort((a, b) => b - a);
|
|
214
|
+
|
|
215
|
+
memoryCache.set(cacheKey, years, CACHE_TIMES.PROJECTS);
|
|
216
|
+
return years;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Invalidate all project caches
|
|
221
|
+
*/
|
|
222
|
+
invalidateCache(): void {
|
|
223
|
+
memoryCache.invalidate('projects:');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Invalidate specific project cache
|
|
228
|
+
*/
|
|
229
|
+
invalidateProjectCache(slug: string): void {
|
|
230
|
+
memoryCache.invalidateKey(`projects:slug:${slug}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export const projectService = ProjectService.getInstance();
|