create-nextjs-stack 0.1.1 → 0.1.2
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 +1 -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/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 +3 -1
- package/templates/web/.env.example +5 -1
- package/templates/web/package-lock.json +49 -18
- package/templates/web/package.json +1 -2
- 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/index.ts +4 -9
- 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,24 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import ResourceFormClient from "@/components/admin/ResourceFormClient";
|
|
4
|
-
import { resources } from "@/config/resources";
|
|
5
|
-
|
|
6
|
-
interface ProjectFormProps {
|
|
7
|
-
mode: "create" | "update";
|
|
8
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
9
|
-
initialData?: any;
|
|
10
|
-
id?: string;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export default function ProjectForm({ mode, initialData, id }: ProjectFormProps) {
|
|
14
|
-
const config = resources.find((r) => r.name === "projects")!;
|
|
15
|
-
|
|
16
|
-
return (
|
|
17
|
-
<ResourceFormClient
|
|
18
|
-
config={config}
|
|
19
|
-
mode={mode}
|
|
20
|
-
initialData={initialData}
|
|
21
|
-
id={id}
|
|
22
|
-
/>
|
|
23
|
-
);
|
|
24
|
-
}
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import Link from "next/link";
|
|
4
|
-
import Image from "next/image";
|
|
5
|
-
import { ResourceConfig } from "@/config/resources";
|
|
6
|
-
import { useResource } from "@/hooks/useResource";
|
|
7
|
-
import { Pencil, Trash2 } from "lucide-react";
|
|
8
|
-
|
|
9
|
-
interface ProjectListProps {
|
|
10
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
11
|
-
items: any[];
|
|
12
|
-
config: ResourceConfig;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
import { useState } from "react";
|
|
16
|
-
import DeleteModal from "@/components/admin/DeleteModal";
|
|
17
|
-
|
|
18
|
-
export default function ProjectList({ items, config }: ProjectListProps) {
|
|
19
|
-
const { remove, isDeleting } = useResource(config);
|
|
20
|
-
const [deleteId, setDeleteId] = useState<string | null>(null);
|
|
21
|
-
|
|
22
|
-
const handleDelete = async () => {
|
|
23
|
-
if (deleteId) {
|
|
24
|
-
const success = await remove(deleteId);
|
|
25
|
-
if (success) {
|
|
26
|
-
setDeleteId(null);
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
return (
|
|
32
|
-
<>
|
|
33
|
-
<div className="bg-white rounded-lg shadow overflow-hidden">
|
|
34
|
-
<div className="overflow-x-auto">
|
|
35
|
-
<table className="w-full text-left">
|
|
36
|
-
<thead className="bg-gray-50 text-gray-500 uppercase text-xs font-semibold">
|
|
37
|
-
<tr>
|
|
38
|
-
<th className="px-6 py-3">Image</th>
|
|
39
|
-
<th className="px-6 py-3">Title</th>
|
|
40
|
-
<th className="px-6 py-3">Client</th>
|
|
41
|
-
<th className="px-6 py-3">Slug</th>
|
|
42
|
-
<th className="px-6 py-3">Published</th>
|
|
43
|
-
<th className="px-6 py-3 text-right">Actions</th>
|
|
44
|
-
</tr>
|
|
45
|
-
</thead>
|
|
46
|
-
<tbody className="divide-y divide-gray-200">
|
|
47
|
-
{items?.map((item) => (
|
|
48
|
-
<tr key={item.id} className="hover:bg-gray-50 transition">
|
|
49
|
-
<td className="px-6 py-4">
|
|
50
|
-
<div className="relative w-10 h-10 bg-gray-100 rounded overflow-hidden">
|
|
51
|
-
{item.featured_image_url ? (
|
|
52
|
-
<Image
|
|
53
|
-
src={item.featured_image_url}
|
|
54
|
-
alt={item.title}
|
|
55
|
-
fill
|
|
56
|
-
className="object-cover"
|
|
57
|
-
/>
|
|
58
|
-
) : (
|
|
59
|
-
<span className="text-xs text-gray-400 flex items-center justify-center h-full">No Img</span>
|
|
60
|
-
)}
|
|
61
|
-
</div>
|
|
62
|
-
</td>
|
|
63
|
-
<td className="px-6 py-4 text-gray-900 font-medium">{item.title}</td>
|
|
64
|
-
<td className="px-6 py-4 text-gray-500">
|
|
65
|
-
{item.clients?.name || "-"}
|
|
66
|
-
</td>
|
|
67
|
-
<td className="px-6 py-4 text-gray-500">{item.slug}</td>
|
|
68
|
-
<td className="px-6 py-4">
|
|
69
|
-
<span
|
|
70
|
-
className={`px-2 py-1 text-xs rounded-full ${
|
|
71
|
-
item.published
|
|
72
|
-
? "bg-green-100 text-green-700"
|
|
73
|
-
: "bg-gray-100 text-gray-600"
|
|
74
|
-
}`}
|
|
75
|
-
>
|
|
76
|
-
{item.published ? "Published" : "Draft"}
|
|
77
|
-
</span>
|
|
78
|
-
</td>
|
|
79
|
-
<td className="px-6 py-4 text-right">
|
|
80
|
-
<div className="flex justify-end gap-2">
|
|
81
|
-
<Link
|
|
82
|
-
href={`/projects/${item.id}`}
|
|
83
|
-
className="flex items-center text-indigo-600 hover:text-indigo-900 bg-indigo-50 hover:bg-indigo-100 px-3 py-1.5 rounded-md transition-colors duration-200"
|
|
84
|
-
title="Edit"
|
|
85
|
-
>
|
|
86
|
-
<Pencil className="w-4 h-4" />
|
|
87
|
-
</Link>
|
|
88
|
-
<button
|
|
89
|
-
onClick={() => setDeleteId(item.id)}
|
|
90
|
-
className="flex items-center text-red-600 hover:text-red-900 bg-red-50 hover:bg-red-100 px-3 py-1.5 rounded-md transition-colors duration-200"
|
|
91
|
-
title="Delete"
|
|
92
|
-
>
|
|
93
|
-
<Trash2 className="w-4 h-4" />
|
|
94
|
-
</button>
|
|
95
|
-
</div>
|
|
96
|
-
</td>
|
|
97
|
-
</tr>
|
|
98
|
-
))}
|
|
99
|
-
{items?.length === 0 && (
|
|
100
|
-
<tr>
|
|
101
|
-
<td colSpan={6} className="px-6 py-8 text-center text-gray-500">
|
|
102
|
-
No projects found.
|
|
103
|
-
</td>
|
|
104
|
-
</tr>
|
|
105
|
-
)}
|
|
106
|
-
</tbody>
|
|
107
|
-
</table>
|
|
108
|
-
</div>
|
|
109
|
-
</div>
|
|
110
|
-
|
|
111
|
-
<DeleteModal
|
|
112
|
-
isOpen={!!deleteId}
|
|
113
|
-
onClose={() => setDeleteId(null)}
|
|
114
|
-
onConfirm={handleDelete}
|
|
115
|
-
isLoading={isDeleting}
|
|
116
|
-
title="Delete Project"
|
|
117
|
-
description="Are you sure you want to delete this project? This action cannot be undone."
|
|
118
|
-
/>
|
|
119
|
-
</>
|
|
120
|
-
);
|
|
121
|
-
}
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import ResourceFormClient from "@/components/admin/ResourceFormClient";
|
|
4
|
-
import { resources } from "@/config/resources";
|
|
5
|
-
|
|
6
|
-
interface UserFormProps {
|
|
7
|
-
mode: "create" | "update";
|
|
8
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
9
|
-
initialData?: any;
|
|
10
|
-
id?: string;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export default function UserForm({ mode, initialData, id }: UserFormProps) {
|
|
14
|
-
const config = resources.find((r) => r.name === "users")!;
|
|
15
|
-
|
|
16
|
-
return (
|
|
17
|
-
<div>
|
|
18
|
-
{mode === 'create' && (
|
|
19
|
-
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4 mb-6">
|
|
20
|
-
<div className="flex">
|
|
21
|
-
<div className="ml-3">
|
|
22
|
-
<p className="text-sm text-yellow-700">
|
|
23
|
-
Note: Creating a user here only creates a profile record.
|
|
24
|
-
It does <strong>not</strong> create a login account.
|
|
25
|
-
Use the Supabase Auth dashboard to invite users.
|
|
26
|
-
</p>
|
|
27
|
-
</div>
|
|
28
|
-
</div>
|
|
29
|
-
</div>
|
|
30
|
-
)}
|
|
31
|
-
<ResourceFormClient
|
|
32
|
-
config={config}
|
|
33
|
-
mode={mode}
|
|
34
|
-
initialData={initialData}
|
|
35
|
-
id={id}
|
|
36
|
-
/>
|
|
37
|
-
</div>
|
|
38
|
-
);
|
|
39
|
-
}
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import Link from "next/link";
|
|
4
|
-
import { ResourceConfig } from "@/config/resources";
|
|
5
|
-
import { useResource } from "@/hooks/useResource";
|
|
6
|
-
import { Pencil, Trash2 } from "lucide-react";
|
|
7
|
-
|
|
8
|
-
interface UserListProps {
|
|
9
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
10
|
-
items: any[];
|
|
11
|
-
config: ResourceConfig;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
import { useState } from "react";
|
|
15
|
-
import DeleteModal from "@/components/admin/DeleteModal";
|
|
16
|
-
|
|
17
|
-
export default function UserList({ items, config }: UserListProps) {
|
|
18
|
-
const { remove, isDeleting } = useResource(config);
|
|
19
|
-
const [deleteId, setDeleteId] = useState<string | null>(null);
|
|
20
|
-
|
|
21
|
-
const handleDelete = async () => {
|
|
22
|
-
if (deleteId) {
|
|
23
|
-
const success = await remove(deleteId);
|
|
24
|
-
if (success) {
|
|
25
|
-
setDeleteId(null);
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
return (
|
|
31
|
-
<>
|
|
32
|
-
<div className="bg-white rounded-lg shadow overflow-hidden">
|
|
33
|
-
<div className="overflow-x-auto">
|
|
34
|
-
<table className="w-full text-left">
|
|
35
|
-
<thead className="bg-gray-50 text-gray-500 uppercase text-xs font-semibold">
|
|
36
|
-
<tr>
|
|
37
|
-
<th className="px-6 py-3">Email</th>
|
|
38
|
-
<th className="px-6 py-3">Full Name</th>
|
|
39
|
-
<th className="px-6 py-3">Role</th>
|
|
40
|
-
<th className="px-6 py-3 text-right">Actions</th>
|
|
41
|
-
</tr>
|
|
42
|
-
</thead>
|
|
43
|
-
<tbody className="divide-y divide-gray-200">
|
|
44
|
-
{items?.map((item) => (
|
|
45
|
-
<tr key={item.id} className="hover:bg-gray-50 transition">
|
|
46
|
-
<td className="px-6 py-4 text-gray-900 font-medium">{item.email}</td>
|
|
47
|
-
<td className="px-6 py-4 text-gray-500">{item.full_name || "-"}</td>
|
|
48
|
-
<td className="px-6 py-4">
|
|
49
|
-
<span
|
|
50
|
-
className={`px-2 py-1 text-xs rounded-full ${
|
|
51
|
-
item.role === "admin"
|
|
52
|
-
? "bg-purple-100 text-purple-700"
|
|
53
|
-
: "bg-blue-100 text-blue-700"
|
|
54
|
-
}`}
|
|
55
|
-
>
|
|
56
|
-
{item.role}
|
|
57
|
-
</span>
|
|
58
|
-
</td>
|
|
59
|
-
<td className="px-6 py-4 text-right">
|
|
60
|
-
<div className="flex justify-end gap-2">
|
|
61
|
-
<Link
|
|
62
|
-
href={`/users/${item.id}`}
|
|
63
|
-
className="flex items-center text-indigo-600 hover:text-indigo-900 bg-indigo-50 hover:bg-indigo-100 px-3 py-1.5 rounded-md transition-colors duration-200"
|
|
64
|
-
title="Edit"
|
|
65
|
-
>
|
|
66
|
-
<Pencil className="w-4 h-4" />
|
|
67
|
-
</Link>
|
|
68
|
-
<button
|
|
69
|
-
onClick={() => setDeleteId(item.id)}
|
|
70
|
-
className="flex items-center text-red-600 hover:text-red-900 bg-red-50 hover:bg-red-100 px-3 py-1.5 rounded-md transition-colors duration-200"
|
|
71
|
-
title="Delete"
|
|
72
|
-
>
|
|
73
|
-
<Trash2 className="w-4 h-4" />
|
|
74
|
-
</button>
|
|
75
|
-
</div>
|
|
76
|
-
</td>
|
|
77
|
-
</tr>
|
|
78
|
-
))}
|
|
79
|
-
{items?.length === 0 && (
|
|
80
|
-
<tr>
|
|
81
|
-
<td colSpan={4} className="px-6 py-8 text-center text-gray-500">
|
|
82
|
-
No users found.
|
|
83
|
-
</td>
|
|
84
|
-
</tr>
|
|
85
|
-
)}
|
|
86
|
-
</tbody>
|
|
87
|
-
</table>
|
|
88
|
-
</div>
|
|
89
|
-
</div>
|
|
90
|
-
|
|
91
|
-
<DeleteModal
|
|
92
|
-
isOpen={!!deleteId}
|
|
93
|
-
onClose={() => setDeleteId(null)}
|
|
94
|
-
onConfirm={handleDelete}
|
|
95
|
-
isLoading={isDeleting}
|
|
96
|
-
title="Delete User"
|
|
97
|
-
description="Are you sure you want to delete this user? This action cannot be undone."
|
|
98
|
-
/>
|
|
99
|
-
</>
|
|
100
|
-
);
|
|
101
|
-
}
|
|
@@ -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();
|