create-nextjs-stack 0.1.1 → 0.1.4
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/README.md +502 -31
- package/package.json +6 -1
- package/templates/admin/app/(dashboard)/[resource]/[id]/page.tsx +6 -5
- package/templates/admin/app/(dashboard)/[resource]/new/page.tsx +11 -12
- package/templates/admin/app/(dashboard)/[resource]/page.tsx +4 -3
- package/templates/admin/app/actions/upload.ts +0 -7
- package/templates/admin/app/globals.css +112 -14
- package/templates/admin/app/layout.tsx +4 -1
- package/templates/admin/components/admin/Sidebar.tsx +2 -5
- package/templates/admin/components.json +22 -0
- package/templates/admin/hooks/useResource.ts +3 -0
- package/templates/admin/lib/services/resource.service.ts +2 -7
- package/templates/admin/lib/supabase/client.ts +8 -4
- package/templates/admin/lib/supabase/server.ts +1 -1
- package/templates/admin/lib/utils.ts +6 -0
- package/templates/admin/middleware.ts +1 -3
- package/templates/admin/next.config.ts +10 -2
- package/templates/admin/package-lock.json +13 -1
- package/templates/admin/package.json +11 -5
- package/templates/admin/src/lib/providers/StoreProvider.tsx +12 -0
- package/templates/admin/src/store/actions/index.ts +2 -0
- package/templates/admin/src/store/hooks.ts +11 -0
- package/templates/admin/src/store/index.ts +17 -0
- package/templates/admin/src/store/reducers/index.ts +11 -0
- package/templates/admin/src/store/types/index.ts +2 -0
- package/templates/admin/tsconfig.json +1 -1
- package/templates/web/.env.example +5 -1
- package/templates/web/package-lock.json +49 -18
- package/templates/web/package.json +3 -4
- package/templates/web/postcss.config.mjs +3 -1
- package/templates/web/src/app/api/revalidate/route.ts +46 -87
- package/templates/web/src/app/globals.css +1 -13
- package/templates/web/src/app/layout.tsx +4 -46
- package/templates/web/src/app/robots.ts +1 -1
- package/templates/web/src/app/sitemap.ts +27 -31
- package/templates/web/src/lib/seo/metadata.ts +5 -5
- package/templates/web/src/lib/seo/seo.config.ts +55 -59
- package/templates/web/src/lib/seo/seo.types.ts +1 -7
- package/templates/web/src/lib/services/categories.service.ts +3 -3
- package/templates/web/src/lib/services/clients.service.ts +2 -2
- package/templates/web/src/lib/services/products.service.ts +3 -3
- package/templates/web/src/lib/services/projects.service.ts +3 -3
- package/templates/web/src/lib/services/users.service.ts +2 -2
- package/templates/web/src/lib/supabase/client.ts +1 -1
- package/templates/web/src/lib/supabase/server.ts +1 -1
- package/templates/web/src/store/hooks.ts +11 -0
- package/templates/web/src/store/index.ts +11 -7
- package/templates/web/src/store/reducers/index.ts +1 -3
- package/templates/admin/app/(dashboard)/categories/[id]/page.tsx +0 -22
- package/templates/admin/app/(dashboard)/categories/new/page.tsx +0 -5
- package/templates/admin/app/(dashboard)/categories/page.tsx +0 -33
- package/templates/admin/app/(dashboard)/clients/[id]/page.tsx +0 -22
- package/templates/admin/app/(dashboard)/clients/new/page.tsx +0 -5
- package/templates/admin/app/(dashboard)/clients/page.tsx +0 -33
- package/templates/admin/app/(dashboard)/products/[id]/page.tsx +0 -22
- package/templates/admin/app/(dashboard)/products/new/page.tsx +0 -5
- package/templates/admin/app/(dashboard)/products/page.tsx +0 -33
- package/templates/admin/app/(dashboard)/projects/[id]/page.tsx +0 -22
- package/templates/admin/app/(dashboard)/projects/new/page.tsx +0 -5
- package/templates/admin/app/(dashboard)/projects/page.tsx +0 -33
- package/templates/admin/app/(dashboard)/users/[id]/page.tsx +0 -22
- package/templates/admin/app/(dashboard)/users/new/page.tsx +0 -5
- package/templates/admin/app/(dashboard)/users/page.tsx +0 -33
- package/templates/admin/components/categories/CategoryForm.tsx +0 -24
- package/templates/admin/components/categories/CategoryList.tsx +0 -113
- package/templates/admin/components/clients/ClientForm.tsx +0 -24
- package/templates/admin/components/clients/ClientList.tsx +0 -113
- package/templates/admin/components/products/ProductForm.tsx +0 -24
- package/templates/admin/components/products/ProductList.tsx +0 -117
- package/templates/admin/components/projects/ProjectForm.tsx +0 -24
- package/templates/admin/components/projects/ProjectList.tsx +0 -121
- package/templates/admin/components/users/UserForm.tsx +0 -39
- package/templates/admin/components/users/UserList.tsx +0 -101
- package/templates/web/src/lib/services/categoryService.ts +0 -251
- package/templates/web/src/lib/services/clientService.ts +0 -132
- package/templates/web/src/lib/services/productService.ts +0 -261
- package/templates/web/src/lib/services/projectService.ts +0 -234
- package/templates/web/src/lib/utils/cache.ts +0 -98
- package/templates/web/src/lib/utils/rate-limiter.ts +0 -102
|
@@ -1,251 +0,0 @@
|
|
|
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 { Category, CategoryWithChildren } from '@/lib/supabase/types';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Category Service - Handles all category-related database operations
|
|
9
|
-
* Implements singleton pattern with caching and rate limiting
|
|
10
|
-
*/
|
|
11
|
-
export class CategoryService {
|
|
12
|
-
private static instance: CategoryService;
|
|
13
|
-
|
|
14
|
-
private constructor() {}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Get singleton instance
|
|
18
|
-
*/
|
|
19
|
-
static getInstance(): CategoryService {
|
|
20
|
-
if (!CategoryService.instance) {
|
|
21
|
-
CategoryService.instance = new CategoryService();
|
|
22
|
-
}
|
|
23
|
-
return CategoryService.instance;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Get all published categories
|
|
28
|
-
*/
|
|
29
|
-
async getAll(): Promise<Category[]> {
|
|
30
|
-
const cacheKey = 'categories:all';
|
|
31
|
-
|
|
32
|
-
// Check memory cache
|
|
33
|
-
const cached = memoryCache.get<Category[]>(cacheKey);
|
|
34
|
-
if (cached) {
|
|
35
|
-
return cached;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// Rate limiting
|
|
39
|
-
const allowed = await perMinuteLimiter.checkLimit('categories:getAll');
|
|
40
|
-
if (!allowed) {
|
|
41
|
-
throw new Error('Rate limit exceeded. Please try again later.');
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Fetch from database
|
|
45
|
-
const supabase = getBrowserClient();
|
|
46
|
-
const { data, error } = await supabase
|
|
47
|
-
.from(TABLE_NAMES.CATEGORIES)
|
|
48
|
-
.select('*')
|
|
49
|
-
.eq('published', true)
|
|
50
|
-
.order('created_at', { ascending: false });
|
|
51
|
-
|
|
52
|
-
if (error) {
|
|
53
|
-
console.error('[CategoryService] Error fetching categories:', error);
|
|
54
|
-
throw new Error(`Failed to fetch categories: ${error.message}`);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Cache result
|
|
58
|
-
memoryCache.set(cacheKey, data, CACHE_TIMES.CATEGORIES);
|
|
59
|
-
|
|
60
|
-
return data;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Get category by slug
|
|
65
|
-
*/
|
|
66
|
-
async getBySlug(slug: string): Promise<Category | null> {
|
|
67
|
-
const cacheKey = `categories:slug:${slug}`;
|
|
68
|
-
|
|
69
|
-
const cached = memoryCache.get<Category>(cacheKey);
|
|
70
|
-
if (cached) {
|
|
71
|
-
return cached;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const allowed = await perMinuteLimiter.checkLimit('categories:getBySlug');
|
|
75
|
-
if (!allowed) {
|
|
76
|
-
throw new Error('Rate limit exceeded. Please try again later.');
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const supabase = getBrowserClient();
|
|
80
|
-
const { data, error } = await supabase
|
|
81
|
-
.from(TABLE_NAMES.CATEGORIES)
|
|
82
|
-
.select('*')
|
|
83
|
-
.eq('slug', slug)
|
|
84
|
-
.eq('published', true)
|
|
85
|
-
.single();
|
|
86
|
-
|
|
87
|
-
if (error) {
|
|
88
|
-
if (error.code === 'PGRST116') {
|
|
89
|
-
// Not found
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
console.error('[CategoryService] Error fetching category:', error);
|
|
93
|
-
throw new Error(`Failed to fetch category: ${error.message}`);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
memoryCache.set(cacheKey, data, CACHE_TIMES.CATEGORIES);
|
|
97
|
-
return data;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Get featured categories
|
|
102
|
-
*/
|
|
103
|
-
async getFeatured(): Promise<Category[]> {
|
|
104
|
-
const cacheKey = 'categories:featured';
|
|
105
|
-
|
|
106
|
-
const cached = memoryCache.get<Category[]>(cacheKey);
|
|
107
|
-
if (cached) {
|
|
108
|
-
return cached;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const allowed = await perMinuteLimiter.checkLimit('categories:getFeatured');
|
|
112
|
-
if (!allowed) {
|
|
113
|
-
throw new Error('Rate limit exceeded. Please try again later.');
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const supabase = getBrowserClient();
|
|
117
|
-
const { data, error } = await supabase
|
|
118
|
-
.from(TABLE_NAMES.CATEGORIES)
|
|
119
|
-
.select('*')
|
|
120
|
-
.eq('published', true)
|
|
121
|
-
.eq('featured', true)
|
|
122
|
-
.order('created_at', { ascending: false });
|
|
123
|
-
|
|
124
|
-
if (error) {
|
|
125
|
-
console.error('[CategoryService] Error fetching featured categories:', error);
|
|
126
|
-
throw new Error(`Failed to fetch featured categories: ${error.message}`);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
memoryCache.set(cacheKey, data, CACHE_TIMES.CATEGORIES);
|
|
130
|
-
return data;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Get sub-categories of a parent category
|
|
135
|
-
*/
|
|
136
|
-
async getSubCategories(parentId: string): Promise<Category[]> {
|
|
137
|
-
const cacheKey = `categories:parent:${parentId}`;
|
|
138
|
-
|
|
139
|
-
const cached = memoryCache.get<Category[]>(cacheKey);
|
|
140
|
-
if (cached) {
|
|
141
|
-
return cached;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const supabase = getBrowserClient();
|
|
145
|
-
const { data, error } = await supabase
|
|
146
|
-
.from(TABLE_NAMES.CATEGORIES)
|
|
147
|
-
.select('*')
|
|
148
|
-
.eq('parent_id', parentId)
|
|
149
|
-
.eq('published', true)
|
|
150
|
-
.order('created_at', { ascending: false });
|
|
151
|
-
|
|
152
|
-
if (error) {
|
|
153
|
-
console.error('[CategoryService] Error fetching sub-categories:', error);
|
|
154
|
-
throw new Error(`Failed to fetch sub-categories: ${error.message}`);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
memoryCache.set(cacheKey, data, CACHE_TIMES.CATEGORIES);
|
|
158
|
-
return data;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Build hierarchical category tree
|
|
163
|
-
*/
|
|
164
|
-
async getTree(): Promise<CategoryWithChildren[]> {
|
|
165
|
-
const cacheKey = 'categories:tree';
|
|
166
|
-
|
|
167
|
-
const cached = memoryCache.get<CategoryWithChildren[]>(cacheKey);
|
|
168
|
-
if (cached) {
|
|
169
|
-
return cached;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// Get all categories
|
|
173
|
-
const categories = await this.getAll();
|
|
174
|
-
|
|
175
|
-
// Build category map
|
|
176
|
-
const categoryMap = new Map<string, CategoryWithChildren>();
|
|
177
|
-
categories.forEach((cat) => {
|
|
178
|
-
categoryMap.set(cat.id, { ...cat, children: [] });
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
// Build tree structure
|
|
182
|
-
const tree: CategoryWithChildren[] = [];
|
|
183
|
-
categories.forEach((cat) => {
|
|
184
|
-
const category = categoryMap.get(cat.id)!;
|
|
185
|
-
|
|
186
|
-
if (cat.parent_id) {
|
|
187
|
-
const parent = categoryMap.get(cat.parent_id);
|
|
188
|
-
if (parent) {
|
|
189
|
-
if (!parent.children) {
|
|
190
|
-
parent.children = [];
|
|
191
|
-
}
|
|
192
|
-
parent.children.push(category);
|
|
193
|
-
}
|
|
194
|
-
} else {
|
|
195
|
-
// Root level category
|
|
196
|
-
tree.push(category);
|
|
197
|
-
}
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
memoryCache.set(cacheKey, tree, CACHE_TIMES.CATEGORIES);
|
|
201
|
-
return tree;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Get category by ID
|
|
206
|
-
*/
|
|
207
|
-
async getById(id: string): Promise<Category | null> {
|
|
208
|
-
const cacheKey = `categories:id:${id}`;
|
|
209
|
-
|
|
210
|
-
const cached = memoryCache.get<Category>(cacheKey);
|
|
211
|
-
if (cached) {
|
|
212
|
-
return cached;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const supabase = getBrowserClient();
|
|
216
|
-
const { data, error } = await supabase
|
|
217
|
-
.from(TABLE_NAMES.CATEGORIES)
|
|
218
|
-
.select('*')
|
|
219
|
-
.eq('id', id)
|
|
220
|
-
.eq('published', true)
|
|
221
|
-
.single();
|
|
222
|
-
|
|
223
|
-
if (error) {
|
|
224
|
-
if (error.code === 'PGRST116') {
|
|
225
|
-
return null;
|
|
226
|
-
}
|
|
227
|
-
console.error('[CategoryService] Error fetching category by ID:', error);
|
|
228
|
-
throw new Error(`Failed to fetch category: ${error.message}`);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
memoryCache.set(cacheKey, data, CACHE_TIMES.CATEGORIES);
|
|
232
|
-
return data;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* Invalidate all category caches
|
|
237
|
-
*/
|
|
238
|
-
invalidateCache(): void {
|
|
239
|
-
memoryCache.invalidate('categories:');
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Invalidate specific category cache
|
|
244
|
-
*/
|
|
245
|
-
invalidateCategoryCache(slug: string): void {
|
|
246
|
-
memoryCache.invalidateKey(`categories:slug:${slug}`);
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// Export singleton instance
|
|
251
|
-
export const categoryService = CategoryService.getInstance();
|
|
@@ -1,132 +0,0 @@
|
|
|
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();
|
|
@@ -1,261 +0,0 @@
|
|
|
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();
|