create-mercato-app 0.4.2-canary-e5804f7db1
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 +94 -0
- package/bin/create-mercato-app +21 -0
- package/dist/index.js +177 -0
- package/package.json +42 -0
- package/template/.env.example +217 -0
- package/template/.yarnrc.yml.template +2 -0
- package/template/components.json +22 -0
- package/template/gitignore +50 -0
- package/template/next.config.ts +28 -0
- package/template/package.json.template +87 -0
- package/template/postcss.config.mjs +5 -0
- package/template/public/catch-the-tornado-logo.png +0 -0
- package/template/public/file.svg +1 -0
- package/template/public/globe.svg +1 -0
- package/template/public/next.svg +1 -0
- package/template/public/open-mercato.svg +50 -0
- package/template/public/vercel.svg +1 -0
- package/template/public/window.svg +1 -0
- package/template/src/app/(backend)/backend/[...slug]/page.tsx +59 -0
- package/template/src/app/(backend)/backend/layout.tsx +350 -0
- package/template/src/app/(backend)/backend/page.tsx +13 -0
- package/template/src/app/(frontend)/[...slug]/page.tsx +32 -0
- package/template/src/app/api/[...slug]/route.ts +227 -0
- package/template/src/app/api/docs/markdown/route.ts +35 -0
- package/template/src/app/api/docs/openapi/route.ts +30 -0
- package/template/src/app/globals.css +178 -0
- package/template/src/app/layout.tsx +76 -0
- package/template/src/app/page.tsx +134 -0
- package/template/src/bootstrap.ts +58 -0
- package/template/src/components/ClientBootstrap.tsx +37 -0
- package/template/src/components/GlobalNoticeBars.tsx +116 -0
- package/template/src/components/OrganizationSwitcher.tsx +360 -0
- package/template/src/components/StartPageContent.tsx +269 -0
- package/template/src/components/ui/button.tsx +59 -0
- package/template/src/components/ui/card.tsx +92 -0
- package/template/src/components/ui/checkbox.tsx +29 -0
- package/template/src/components/ui/input.tsx +21 -0
- package/template/src/components/ui/label.tsx +24 -0
- package/template/src/di.ts +11 -0
- package/template/src/i18n/de.json +375 -0
- package/template/src/i18n/en.json +376 -0
- package/template/src/i18n/es.json +376 -0
- package/template/src/i18n/pl.json +375 -0
- package/template/src/modules/.gitkeep +0 -0
- package/template/src/modules.ts +31 -0
- package/template/src/proxy.ts +17 -0
- package/template/tsconfig.json +54 -0
- package/template/types/pg/index.d.ts +1 -0
- package/template/types/react-big-calendar/index.d.ts +16 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from 'next/server'
|
|
2
|
+
import { findApi, type HttpMethod } from '@open-mercato/shared/modules/registry'
|
|
3
|
+
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
4
|
+
import { modules } from '@/.mercato/generated/modules.generated'
|
|
5
|
+
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
6
|
+
import { bootstrap } from '@/bootstrap'
|
|
7
|
+
|
|
8
|
+
// Ensure all package registrations are initialized for API routes
|
|
9
|
+
bootstrap()
|
|
10
|
+
import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
|
|
11
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
12
|
+
import { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
13
|
+
import { resolveFeatureCheckContext } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
14
|
+
import { enforceTenantSelection, normalizeTenantId } from '@open-mercato/core/modules/auth/lib/tenantAccess'
|
|
15
|
+
import { runWithCacheTenant } from '@open-mercato/cache'
|
|
16
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
17
|
+
|
|
18
|
+
type MethodMetadata = {
|
|
19
|
+
requireAuth?: boolean
|
|
20
|
+
requireRoles?: string[]
|
|
21
|
+
requireFeatures?: string[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type HandlerContext = {
|
|
25
|
+
params: Record<string, string | string[]>
|
|
26
|
+
auth: AuthContext
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function extractMethodMetadata(metadata: unknown, method: HttpMethod): MethodMetadata | null {
|
|
30
|
+
if (!metadata || typeof metadata !== 'object') return null
|
|
31
|
+
const entry = (metadata as Partial<Record<HttpMethod, unknown>>)[method]
|
|
32
|
+
if (!entry || typeof entry !== 'object') return null
|
|
33
|
+
const source = entry as Record<string, unknown>
|
|
34
|
+
const normalized: MethodMetadata = {}
|
|
35
|
+
if (typeof source.requireAuth === 'boolean') normalized.requireAuth = source.requireAuth
|
|
36
|
+
if (Array.isArray(source.requireRoles)) {
|
|
37
|
+
normalized.requireRoles = source.requireRoles.filter((role): role is string => typeof role === 'string' && role.length > 0)
|
|
38
|
+
}
|
|
39
|
+
if (Array.isArray(source.requireFeatures)) {
|
|
40
|
+
normalized.requireFeatures = source.requireFeatures.filter((feature): feature is string => typeof feature === 'string' && feature.length > 0)
|
|
41
|
+
}
|
|
42
|
+
return normalized
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function checkAuthorization(
|
|
46
|
+
methodMetadata: MethodMetadata | null,
|
|
47
|
+
auth: AuthContext,
|
|
48
|
+
req: NextRequest
|
|
49
|
+
): Promise<NextResponse | null> {
|
|
50
|
+
const { t } = await resolveTranslations()
|
|
51
|
+
if (methodMetadata?.requireAuth && !auth) {
|
|
52
|
+
return NextResponse.json({ error: t('api.errors.unauthorized', 'Unauthorized') }, { status: 401 })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const requiredRoles = methodMetadata?.requireRoles ?? []
|
|
56
|
+
const requiredFeatures = methodMetadata?.requireFeatures ?? []
|
|
57
|
+
|
|
58
|
+
if (
|
|
59
|
+
requiredRoles.length &&
|
|
60
|
+
(!auth || !Array.isArray(auth.roles) || !requiredRoles.some((role) => auth.roles!.includes(role)))
|
|
61
|
+
) {
|
|
62
|
+
return NextResponse.json({ error: t('api.errors.forbidden', 'Forbidden'), requiredRoles }, { status: 403 })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let container: Awaited<ReturnType<typeof createRequestContainer>> | null = null
|
|
66
|
+
const ensureContainer = async () => {
|
|
67
|
+
if (!container) container = await createRequestContainer()
|
|
68
|
+
return container
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (auth) {
|
|
72
|
+
const rawTenantCandidate = await extractTenantCandidate(req)
|
|
73
|
+
if (rawTenantCandidate !== undefined) {
|
|
74
|
+
const tenantCandidate = sanitizeTenantCandidate(rawTenantCandidate)
|
|
75
|
+
if (tenantCandidate !== undefined) {
|
|
76
|
+
const normalizedCandidate = normalizeTenantId(tenantCandidate) ?? null
|
|
77
|
+
const actorTenant = normalizeTenantId(auth.tenantId ?? null) ?? null
|
|
78
|
+
const tenantDiffers = normalizedCandidate !== actorTenant
|
|
79
|
+
if (tenantDiffers) {
|
|
80
|
+
try {
|
|
81
|
+
const guardContainer = await ensureContainer()
|
|
82
|
+
await enforceTenantSelection({ auth, container: guardContainer }, tenantCandidate)
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error instanceof CrudHttpError) {
|
|
85
|
+
return NextResponse.json(error.body ?? { error: t('api.errors.forbidden', 'Forbidden') }, { status: error.status })
|
|
86
|
+
}
|
|
87
|
+
throw error
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (requiredFeatures.length) {
|
|
95
|
+
if (!auth) {
|
|
96
|
+
return NextResponse.json({ error: t('api.errors.unauthorized', 'Unauthorized') }, { status: 401 })
|
|
97
|
+
}
|
|
98
|
+
const featureContainer = await ensureContainer()
|
|
99
|
+
const rbac = featureContainer.resolve<RbacService>('rbacService')
|
|
100
|
+
const featureContext = await resolveFeatureCheckContext({ container: featureContainer, auth, request: req })
|
|
101
|
+
const { organizationId } = featureContext
|
|
102
|
+
const ok = await rbac.userHasAllFeatures(auth.sub, requiredFeatures, {
|
|
103
|
+
tenantId: featureContext.scope.tenantId ?? auth.tenantId ?? null,
|
|
104
|
+
organizationId,
|
|
105
|
+
})
|
|
106
|
+
if (!ok) {
|
|
107
|
+
try {
|
|
108
|
+
const acl = await rbac.loadAcl(auth.sub, { tenantId: featureContext.scope.tenantId ?? auth.tenantId ?? null, organizationId })
|
|
109
|
+
console.warn('[api] Forbidden - missing required features', {
|
|
110
|
+
path: req.nextUrl.pathname,
|
|
111
|
+
method: req.method,
|
|
112
|
+
userId: auth.sub,
|
|
113
|
+
tenantId: featureContext.scope.tenantId ?? auth.tenantId ?? null,
|
|
114
|
+
selectedOrganizationId: featureContext.scope.selectedId,
|
|
115
|
+
organizationId,
|
|
116
|
+
requiredFeatures,
|
|
117
|
+
grantedFeatures: acl.features,
|
|
118
|
+
isSuperAdmin: acl.isSuperAdmin,
|
|
119
|
+
allowedOrganizations: acl.organizations,
|
|
120
|
+
})
|
|
121
|
+
} catch (err) {
|
|
122
|
+
try {
|
|
123
|
+
console.warn('[api] Forbidden - could not resolve ACL for logging', {
|
|
124
|
+
path: req.nextUrl.pathname,
|
|
125
|
+
method: req.method,
|
|
126
|
+
userId: auth.sub,
|
|
127
|
+
tenantId: featureContext.scope.tenantId ?? auth.tenantId ?? null,
|
|
128
|
+
organizationId,
|
|
129
|
+
requiredFeatures,
|
|
130
|
+
error: err instanceof Error ? err.message : err,
|
|
131
|
+
})
|
|
132
|
+
} catch {
|
|
133
|
+
// best-effort logging; ignore secondary failures
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return NextResponse.json({ error: t('api.errors.forbidden', 'Forbidden'), requiredFeatures }, { status: 403 })
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function sanitizeTenantCandidate(candidate: unknown): unknown {
|
|
144
|
+
if (typeof candidate === 'string') {
|
|
145
|
+
const lowered = candidate.trim().toLowerCase()
|
|
146
|
+
if (lowered === 'null') return null
|
|
147
|
+
if (lowered === 'undefined') return undefined
|
|
148
|
+
return candidate.trim()
|
|
149
|
+
}
|
|
150
|
+
return candidate
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function extractTenantCandidate(req: NextRequest): Promise<unknown> {
|
|
154
|
+
const tenantParams = req.nextUrl?.searchParams?.getAll?.('tenantId') ?? []
|
|
155
|
+
if (tenantParams.length > 0) {
|
|
156
|
+
return tenantParams[tenantParams.length - 1]
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const method = (req.method || 'GET').toUpperCase()
|
|
160
|
+
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') {
|
|
161
|
+
return undefined
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const rawContentType = req.headers.get('content-type')
|
|
165
|
+
if (!rawContentType) return undefined
|
|
166
|
+
const contentType = rawContentType.split(';')[0].trim().toLowerCase()
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
if (contentType === 'application/json') {
|
|
170
|
+
const payload = await req.clone().json()
|
|
171
|
+
if (payload && typeof payload === 'object' && !Array.isArray(payload) && 'tenantId' in payload) {
|
|
172
|
+
return (payload as Record<string, unknown>).tenantId
|
|
173
|
+
}
|
|
174
|
+
} else if (contentType === 'application/x-www-form-urlencoded' || contentType === 'multipart/form-data') {
|
|
175
|
+
const form = await req.clone().formData()
|
|
176
|
+
if (form.has('tenantId')) {
|
|
177
|
+
const value = form.get('tenantId')
|
|
178
|
+
if (value instanceof File) return value.name
|
|
179
|
+
return value
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
// Ignore parsing failures; downstream handlers can deal with malformed payloads.
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return undefined
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function handleRequest(
|
|
190
|
+
method: HttpMethod,
|
|
191
|
+
req: NextRequest,
|
|
192
|
+
paramsPromise: Promise<{ slug: string[] }>
|
|
193
|
+
): Promise<Response> {
|
|
194
|
+
const { t } = await resolveTranslations()
|
|
195
|
+
const params = await paramsPromise
|
|
196
|
+
const pathname = '/' + (params.slug?.join('/') ?? '')
|
|
197
|
+
const api = findApi(modules, method, pathname)
|
|
198
|
+
if (!api) return NextResponse.json({ error: t('api.errors.notFound', 'Not Found') }, { status: 404 })
|
|
199
|
+
const auth = await getAuthFromRequest(req)
|
|
200
|
+
|
|
201
|
+
const methodMetadata = extractMethodMetadata(api.metadata, method)
|
|
202
|
+
const authError = await checkAuthorization(methodMetadata, auth, req)
|
|
203
|
+
if (authError) return authError
|
|
204
|
+
|
|
205
|
+
const handlerContext: HandlerContext = { params: api.params, auth }
|
|
206
|
+
return await runWithCacheTenant(auth?.tenantId ?? null, () => api.handler(req, handlerContext))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function GET(req: NextRequest, { params }: { params: Promise<{ slug: string[] }> }) {
|
|
210
|
+
return handleRequest('GET', req, params)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function POST(req: NextRequest, { params }: { params: Promise<{ slug: string[] }> }) {
|
|
214
|
+
return handleRequest('POST', req, params)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function PUT(req: NextRequest, { params }: { params: Promise<{ slug: string[] }> }) {
|
|
218
|
+
return handleRequest('PUT', req, params)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ slug: string[] }> }) {
|
|
222
|
+
return handleRequest('PATCH', req, params)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ slug: string[] }> }) {
|
|
226
|
+
return handleRequest('DELETE', req, params)
|
|
227
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { modules } from '@/.mercato/generated/modules.generated'
|
|
2
|
+
import { buildOpenApiDocument, generateMarkdownFromOpenApi, sanitizeOpenApiDocument } from '@open-mercato/shared/lib/openapi'
|
|
3
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
4
|
+
|
|
5
|
+
export const dynamic = 'force-dynamic'
|
|
6
|
+
|
|
7
|
+
function resolveBaseUrl() {
|
|
8
|
+
return (
|
|
9
|
+
process.env.NEXT_PUBLIC_API_BASE_URL ||
|
|
10
|
+
process.env.NEXT_PUBLIC_APP_URL ||
|
|
11
|
+
process.env.APP_URL ||
|
|
12
|
+
'http://localhost:3000'
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function GET() {
|
|
17
|
+
const { t } = await resolveTranslations()
|
|
18
|
+
const baseUrl = resolveBaseUrl()
|
|
19
|
+
const rawDoc = buildOpenApiDocument(modules, {
|
|
20
|
+
title: t('api.docs.title', 'Open Mercato API'),
|
|
21
|
+
version: '1.0.0',
|
|
22
|
+
description: t('api.docs.description', 'Auto-generated OpenAPI definition for all enabled modules.'),
|
|
23
|
+
servers: [{ url: baseUrl, description: t('api.docs.serverDescription', 'Default environment') }],
|
|
24
|
+
baseUrlForExamples: baseUrl,
|
|
25
|
+
defaultSecurity: ['bearerAuth'],
|
|
26
|
+
})
|
|
27
|
+
const doc = sanitizeOpenApiDocument(rawDoc)
|
|
28
|
+
const markdown = generateMarkdownFromOpenApi(doc)
|
|
29
|
+
return new Response(markdown, {
|
|
30
|
+
headers: {
|
|
31
|
+
'content-type': 'text/markdown; charset=utf-8',
|
|
32
|
+
'cache-control': 'no-store',
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { modules } from '@/.mercato/generated/modules.generated'
|
|
3
|
+
import { buildOpenApiDocument, sanitizeOpenApiDocument } from '@open-mercato/shared/lib/openapi'
|
|
4
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
5
|
+
|
|
6
|
+
export const dynamic = 'force-dynamic'
|
|
7
|
+
|
|
8
|
+
function resolveBaseUrl() {
|
|
9
|
+
return (
|
|
10
|
+
process.env.NEXT_PUBLIC_API_BASE_URL ||
|
|
11
|
+
process.env.NEXT_PUBLIC_APP_URL ||
|
|
12
|
+
process.env.APP_URL ||
|
|
13
|
+
'http://localhost:3000'
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function GET() {
|
|
18
|
+
const { t } = await resolveTranslations()
|
|
19
|
+
const baseUrl = resolveBaseUrl()
|
|
20
|
+
const rawDoc = buildOpenApiDocument(modules, {
|
|
21
|
+
title: t('api.docs.title', 'Open Mercato API'),
|
|
22
|
+
version: '1.0.0',
|
|
23
|
+
description: t('api.docs.description', 'Auto-generated OpenAPI definition for all enabled modules.'),
|
|
24
|
+
servers: [{ url: baseUrl, description: t('api.docs.serverDescription', 'Default environment') }],
|
|
25
|
+
baseUrlForExamples: baseUrl,
|
|
26
|
+
defaultSecurity: ['bearerAuth'],
|
|
27
|
+
})
|
|
28
|
+
const doc = sanitizeOpenApiDocument(rawDoc)
|
|
29
|
+
return NextResponse.json(doc)
|
|
30
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
@import "tw-animate-css";
|
|
3
|
+
@import "react-big-calendar/lib/css/react-big-calendar.css";
|
|
4
|
+
@import "@xyflow/react/dist/style.css";
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
Include @open-mercato packages in Tailwind content scanning
|
|
8
|
+
For standalone apps, packages are in node_modules with compiled dist/ output
|
|
9
|
+
*/
|
|
10
|
+
@source "../../node_modules/@open-mercato/ui/dist/**/*.js";
|
|
11
|
+
@source "../../node_modules/@open-mercato/core/dist/**/*.js";
|
|
12
|
+
@source "../../node_modules/@open-mercato/shared/dist/**/*.js";
|
|
13
|
+
@source "../../node_modules/@open-mercato/search/dist/**/*.js";
|
|
14
|
+
@source "../../node_modules/@open-mercato/onboarding/dist/**/*.js";
|
|
15
|
+
@source "../../node_modules/@open-mercato/content/dist/**/*.js";
|
|
16
|
+
@source "../../node_modules/@open-mercato/ai-assistant/dist/**/*.js";
|
|
17
|
+
|
|
18
|
+
@custom-variant dark (&:is(.dark *));
|
|
19
|
+
|
|
20
|
+
@theme inline {
|
|
21
|
+
--color-background: var(--background);
|
|
22
|
+
--color-foreground: var(--foreground);
|
|
23
|
+
--font-sans: var(--font-geist-sans);
|
|
24
|
+
--font-mono: var(--font-geist-mono);
|
|
25
|
+
--color-sidebar-ring: var(--sidebar-ring);
|
|
26
|
+
--color-sidebar-border: var(--sidebar-border);
|
|
27
|
+
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
28
|
+
--color-sidebar-accent: var(--sidebar-accent);
|
|
29
|
+
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
30
|
+
--color-sidebar-primary: var(--sidebar-primary);
|
|
31
|
+
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
32
|
+
--color-sidebar: var(--sidebar);
|
|
33
|
+
--color-chart-5: var(--chart-5);
|
|
34
|
+
--color-chart-4: var(--chart-4);
|
|
35
|
+
--color-chart-3: var(--chart-3);
|
|
36
|
+
--color-chart-2: var(--chart-2);
|
|
37
|
+
--color-chart-1: var(--chart-1);
|
|
38
|
+
--color-ring: var(--ring);
|
|
39
|
+
--color-input: var(--input);
|
|
40
|
+
--color-border: var(--border);
|
|
41
|
+
--color-destructive: var(--destructive);
|
|
42
|
+
--color-accent-foreground: var(--accent-foreground);
|
|
43
|
+
--color-accent: var(--accent);
|
|
44
|
+
--color-muted-foreground: var(--muted-foreground);
|
|
45
|
+
--color-muted: var(--muted);
|
|
46
|
+
--color-secondary-foreground: var(--secondary-foreground);
|
|
47
|
+
--color-secondary: var(--secondary);
|
|
48
|
+
--color-primary-foreground: var(--primary-foreground);
|
|
49
|
+
--color-primary: var(--primary);
|
|
50
|
+
--color-popover-foreground: var(--popover-foreground);
|
|
51
|
+
--color-popover: var(--popover);
|
|
52
|
+
--color-card-foreground: var(--card-foreground);
|
|
53
|
+
--color-card: var(--card);
|
|
54
|
+
--radius-sm: calc(var(--radius) - 4px);
|
|
55
|
+
--radius-md: calc(var(--radius) - 2px);
|
|
56
|
+
--radius-lg: var(--radius);
|
|
57
|
+
--radius-xl: calc(var(--radius) + 4px);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
:root {
|
|
61
|
+
--radius: 0.625rem;
|
|
62
|
+
--background: oklch(1 0 0);
|
|
63
|
+
--foreground: oklch(0.145 0 0);
|
|
64
|
+
--card: oklch(1 0 0);
|
|
65
|
+
--card-foreground: oklch(0.145 0 0);
|
|
66
|
+
--popover: oklch(1 0 0);
|
|
67
|
+
--popover-foreground: oklch(0.145 0 0);
|
|
68
|
+
--primary: oklch(0.205 0 0);
|
|
69
|
+
--primary-foreground: oklch(0.985 0 0);
|
|
70
|
+
--secondary: oklch(0.97 0 0);
|
|
71
|
+
--secondary-foreground: oklch(0.205 0 0);
|
|
72
|
+
--muted: oklch(0.97 0 0);
|
|
73
|
+
--muted-foreground: oklch(0.556 0 0);
|
|
74
|
+
--accent: oklch(0.97 0 0);
|
|
75
|
+
--accent-foreground: oklch(0.205 0 0);
|
|
76
|
+
--destructive: oklch(0.577 0.245 27.325);
|
|
77
|
+
--border: oklch(0.922 0 0);
|
|
78
|
+
--input: oklch(0.922 0 0);
|
|
79
|
+
--ring: oklch(0.708 0 0);
|
|
80
|
+
--chart-1: oklch(0.646 0.222 41.116);
|
|
81
|
+
--chart-2: oklch(0.6 0.118 184.704);
|
|
82
|
+
--chart-3: oklch(0.398 0.07 227.392);
|
|
83
|
+
--chart-4: oklch(0.828 0.189 84.429);
|
|
84
|
+
--chart-5: oklch(0.769 0.188 70.08);
|
|
85
|
+
--sidebar: oklch(0.985 0 0);
|
|
86
|
+
--sidebar-foreground: oklch(0.145 0 0);
|
|
87
|
+
--sidebar-primary: oklch(0.205 0 0);
|
|
88
|
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
89
|
+
--sidebar-accent: oklch(0.97 0 0);
|
|
90
|
+
--sidebar-accent-foreground: oklch(0.205 0 0);
|
|
91
|
+
--sidebar-border: oklch(0.922 0 0);
|
|
92
|
+
--sidebar-ring: oklch(0.708 0 0);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.dark {
|
|
96
|
+
--background: oklch(0.145 0 0);
|
|
97
|
+
--foreground: oklch(0.985 0 0);
|
|
98
|
+
--card: oklch(0.205 0 0);
|
|
99
|
+
--card-foreground: oklch(0.985 0 0);
|
|
100
|
+
--popover: oklch(0.205 0 0);
|
|
101
|
+
--popover-foreground: oklch(0.985 0 0);
|
|
102
|
+
--primary: oklch(0.922 0 0);
|
|
103
|
+
--primary-foreground: oklch(0.205 0 0);
|
|
104
|
+
--secondary: oklch(0.269 0 0);
|
|
105
|
+
--secondary-foreground: oklch(0.985 0 0);
|
|
106
|
+
--muted: oklch(0.269 0 0);
|
|
107
|
+
--muted-foreground: oklch(0.708 0 0);
|
|
108
|
+
--accent: oklch(0.269 0 0);
|
|
109
|
+
--accent-foreground: oklch(0.985 0 0);
|
|
110
|
+
--destructive: oklch(0.704 0.191 22.216);
|
|
111
|
+
--border: oklch(1 0 0 / 10%);
|
|
112
|
+
--input: oklch(1 0 0 / 15%);
|
|
113
|
+
--ring: oklch(0.556 0 0);
|
|
114
|
+
--chart-1: oklch(0.488 0.243 264.376);
|
|
115
|
+
--chart-2: oklch(0.696 0.17 162.48);
|
|
116
|
+
--chart-3: oklch(0.769 0.188 70.08);
|
|
117
|
+
--chart-4: oklch(0.627 0.265 303.9);
|
|
118
|
+
--chart-5: oklch(0.645 0.246 16.439);
|
|
119
|
+
--sidebar: oklch(0.205 0 0);
|
|
120
|
+
--sidebar-foreground: oklch(0.985 0 0);
|
|
121
|
+
--sidebar-primary: oklch(0.488 0.243 264.376);
|
|
122
|
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
123
|
+
--sidebar-accent: oklch(0.269 0 0);
|
|
124
|
+
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
125
|
+
--sidebar-border: oklch(1 0 0 / 10%);
|
|
126
|
+
--sidebar-ring: oklch(0.556 0 0);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
@layer base {
|
|
130
|
+
* {
|
|
131
|
+
@apply border-border outline-ring/50;
|
|
132
|
+
}
|
|
133
|
+
body {
|
|
134
|
+
@apply bg-background text-foreground;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.rbc-calendar {
|
|
139
|
+
font-family: var(--font-sans);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.rbc-toolbar,
|
|
143
|
+
.rbc-time-header,
|
|
144
|
+
.rbc-time-content,
|
|
145
|
+
.rbc-agenda-view,
|
|
146
|
+
.rbc-month-view {
|
|
147
|
+
color: var(--foreground);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.rbc-today {
|
|
151
|
+
background-color: color-mix(in oklab, var(--accent) 40%, transparent);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.schedule-calendar .rbc-time-gutter,
|
|
155
|
+
.schedule-calendar .rbc-agenda-time-cell,
|
|
156
|
+
.schedule-calendar .rbc-agenda-date-cell {
|
|
157
|
+
font-size: 0.75rem;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.schedule-calendar .rbc-time-content {
|
|
161
|
+
font-size: 0.8125rem;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* Flash message animations */
|
|
165
|
+
@keyframes slide-in {
|
|
166
|
+
from {
|
|
167
|
+
transform: translateX(100%);
|
|
168
|
+
opacity: 0;
|
|
169
|
+
}
|
|
170
|
+
to {
|
|
171
|
+
transform: translateX(0);
|
|
172
|
+
opacity: 1;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.animate-slide-in {
|
|
177
|
+
animation: slide-in 0.3s ease-out;
|
|
178
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Metadata } from 'next'
|
|
2
|
+
import { Geist, Geist_Mono } from 'next/font/google'
|
|
3
|
+
import './globals.css'
|
|
4
|
+
import { bootstrap } from '@/bootstrap'
|
|
5
|
+
import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
|
|
6
|
+
|
|
7
|
+
// Bootstrap all package registrations at module load time
|
|
8
|
+
bootstrap()
|
|
9
|
+
import { ThemeProvider, FrontendLayout, QueryProvider, AuthFooter } from '@open-mercato/ui'
|
|
10
|
+
import { ClientBootstrapProvider } from '@/components/ClientBootstrap'
|
|
11
|
+
import { GlobalNoticeBars } from '@/components/GlobalNoticeBars'
|
|
12
|
+
import { detectLocale, loadDictionary, resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
13
|
+
|
|
14
|
+
const geistSans = Geist({
|
|
15
|
+
variable: "--font-geist-sans",
|
|
16
|
+
subsets: ["latin"],
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const geistMono = Geist_Mono({
|
|
20
|
+
variable: "--font-geist-mono",
|
|
21
|
+
subsets: ["latin"],
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
25
|
+
const { t } = await resolveTranslations()
|
|
26
|
+
return {
|
|
27
|
+
title: t('app.metadata.title', 'Open Mercato'),
|
|
28
|
+
description: t('app.metadata.description', 'AI‑supportive, modular ERP foundation for product & service companies'),
|
|
29
|
+
icons: {
|
|
30
|
+
icon: "/open-mercato.svg",
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default async function RootLayout({
|
|
36
|
+
children,
|
|
37
|
+
}: Readonly<{
|
|
38
|
+
children: React.ReactNode;
|
|
39
|
+
}>) {
|
|
40
|
+
const locale = await detectLocale()
|
|
41
|
+
const dict = await loadDictionary(locale)
|
|
42
|
+
const demoModeEnabled = process.env.DEMO_MODE !== 'false'
|
|
43
|
+
return (
|
|
44
|
+
<html lang={locale} suppressHydrationWarning>
|
|
45
|
+
<head>
|
|
46
|
+
<script
|
|
47
|
+
dangerouslySetInnerHTML={{
|
|
48
|
+
__html: `
|
|
49
|
+
(function() {
|
|
50
|
+
try {
|
|
51
|
+
var stored = localStorage.getItem('om-theme');
|
|
52
|
+
var theme = stored === 'dark' ? 'dark'
|
|
53
|
+
: stored === 'light' ? 'light'
|
|
54
|
+
: window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
55
|
+
if (theme === 'dark') document.documentElement.classList.add('dark');
|
|
56
|
+
} catch (e) {}
|
|
57
|
+
})();
|
|
58
|
+
`,
|
|
59
|
+
}}
|
|
60
|
+
/>
|
|
61
|
+
</head>
|
|
62
|
+
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`} suppressHydrationWarning data-gramm="false">
|
|
63
|
+
<I18nProvider locale={locale} dict={dict}>
|
|
64
|
+
<ClientBootstrapProvider>
|
|
65
|
+
<ThemeProvider>
|
|
66
|
+
<QueryProvider>
|
|
67
|
+
<FrontendLayout footer={<AuthFooter />}>{children}</FrontendLayout>
|
|
68
|
+
<GlobalNoticeBars demoModeEnabled={demoModeEnabled} />
|
|
69
|
+
</QueryProvider>
|
|
70
|
+
</ThemeProvider>
|
|
71
|
+
</ClientBootstrapProvider>
|
|
72
|
+
</I18nProvider>
|
|
73
|
+
</body>
|
|
74
|
+
</html>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { modules } from '@/.mercato/generated/modules.generated'
|
|
2
|
+
import { StartPageContent } from '@/components/StartPageContent'
|
|
3
|
+
import { cookies } from 'next/headers'
|
|
4
|
+
import Image from 'next/image'
|
|
5
|
+
import Link from 'next/link'
|
|
6
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
7
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
8
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
9
|
+
|
|
10
|
+
function FeatureBadge({ label }: { label: string }) {
|
|
11
|
+
return (
|
|
12
|
+
<span className="inline-flex items-center rounded border px-2 py-0.5 text-xs text-muted-foreground">
|
|
13
|
+
{label}
|
|
14
|
+
</span>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default async function Home() {
|
|
19
|
+
const { t } = await resolveTranslations()
|
|
20
|
+
|
|
21
|
+
// Check if user wants to see the start page
|
|
22
|
+
const cookieStore = await cookies()
|
|
23
|
+
const showStartPageCookie = cookieStore.get('show_start_page')
|
|
24
|
+
const showStartPage = showStartPageCookie?.value !== 'false'
|
|
25
|
+
|
|
26
|
+
// Database status and counts
|
|
27
|
+
let dbStatus = t('app.page.dbStatus.unknown', 'Unknown')
|
|
28
|
+
let usersCount = 0
|
|
29
|
+
let tenantsCount = 0
|
|
30
|
+
let orgsCount = 0
|
|
31
|
+
try {
|
|
32
|
+
const container = await createRequestContainer()
|
|
33
|
+
const em = container.resolve<EntityManager>('em')
|
|
34
|
+
usersCount = await em.count('User', {})
|
|
35
|
+
tenantsCount = await em.count('Tenant', {})
|
|
36
|
+
orgsCount = await em.count('Organization', {})
|
|
37
|
+
dbStatus = t('app.page.dbStatus.connected', 'Connected')
|
|
38
|
+
} catch (error: unknown) {
|
|
39
|
+
const message = error instanceof Error ? error.message : t('app.page.dbStatus.noConnection', 'no connection')
|
|
40
|
+
dbStatus = t('app.page.dbStatus.error', 'Error: {message}', { message })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const onboardingAvailable =
|
|
44
|
+
process.env.SELF_SERVICE_ONBOARDING_ENABLED === 'true' &&
|
|
45
|
+
Boolean(process.env.RESEND_API_KEY && process.env.RESEND_API_KEY.trim()) &&
|
|
46
|
+
Boolean(process.env.APP_URL && process.env.APP_URL.trim())
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<main className="min-h-svh w-full p-8 flex flex-col gap-8">
|
|
50
|
+
<header className="flex flex-col md:flex-row md:items-center gap-4 md:gap-6">
|
|
51
|
+
<Image
|
|
52
|
+
src="/open-mercato.svg"
|
|
53
|
+
alt={t('app.page.logoAlt', 'Open Mercato')}
|
|
54
|
+
width={40}
|
|
55
|
+
height={40}
|
|
56
|
+
className="dark:invert"
|
|
57
|
+
priority
|
|
58
|
+
/>
|
|
59
|
+
<div className="flex-1">
|
|
60
|
+
<h1 className="text-3xl font-semibold tracking-tight">{t('app.page.title', 'Open Mercato')}</h1>
|
|
61
|
+
<p className="text-sm text-muted-foreground">{t('app.page.subtitle', 'AI‑supportive, modular ERP foundation for product & service companies')}</p>
|
|
62
|
+
</div>
|
|
63
|
+
</header>
|
|
64
|
+
|
|
65
|
+
<StartPageContent showStartPage={showStartPage} showOnboardingCta={onboardingAvailable} />
|
|
66
|
+
|
|
67
|
+
<section className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
68
|
+
<div className="rounded-lg border bg-card p-4">
|
|
69
|
+
<div className="text-sm font-medium mb-2">{t('app.page.dbStatus.title', 'Database Status')}</div>
|
|
70
|
+
<div className="text-sm text-muted-foreground">{t('app.page.dbStatus.label', 'Status:')} <span className="font-medium text-foreground">{dbStatus}</span></div>
|
|
71
|
+
<div className="mt-3 space-y-1.5 text-sm">
|
|
72
|
+
<div className="flex justify-between">
|
|
73
|
+
<span className="text-muted-foreground">{t('app.page.dbStatus.users', 'Users:')}</span>
|
|
74
|
+
<span className="font-mono font-medium">{usersCount}</span>
|
|
75
|
+
</div>
|
|
76
|
+
<div className="flex justify-between">
|
|
77
|
+
<span className="text-muted-foreground">{t('app.page.dbStatus.tenants', 'Tenants:')}</span>
|
|
78
|
+
<span className="font-mono font-medium">{tenantsCount}</span>
|
|
79
|
+
</div>
|
|
80
|
+
<div className="flex justify-between">
|
|
81
|
+
<span className="text-muted-foreground">{t('app.page.dbStatus.organizations', 'Organizations:')}</span>
|
|
82
|
+
<span className="font-mono font-medium">{orgsCount}</span>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div className="rounded-lg border bg-card p-4 md:col-span-2">
|
|
88
|
+
<div className="text-sm font-medium mb-3">{t('app.page.activeModules.title', 'Active Modules')}</div>
|
|
89
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 max-h-[200px] overflow-y-auto pr-2">
|
|
90
|
+
{modules.map((m) => {
|
|
91
|
+
const fe = m.frontendRoutes?.length || 0
|
|
92
|
+
const be = m.backendRoutes?.length || 0
|
|
93
|
+
const api = m.apis?.length || 0
|
|
94
|
+
const cli = m.cli?.length || 0
|
|
95
|
+
const i18n = m.translations ? Object.keys(m.translations).length : 0
|
|
96
|
+
return (
|
|
97
|
+
<div key={m.id} className="rounded border p-3 bg-background">
|
|
98
|
+
<div className="text-sm font-medium">{m.info?.title || m.id}{m.info?.version ? <span className="ml-2 text-xs text-muted-foreground">v{m.info.version}</span> : null}</div>
|
|
99
|
+
{m.info?.description ? <div className="text-xs text-muted-foreground mt-1 line-clamp-2">{m.info.description}</div> : null}
|
|
100
|
+
<div className="mt-2 flex flex-wrap gap-1">
|
|
101
|
+
{fe ? <FeatureBadge label={`FE:${fe}`} /> : null}
|
|
102
|
+
{be ? <FeatureBadge label={`BE:${be}`} /> : null}
|
|
103
|
+
{api ? <FeatureBadge label={`API:${api}`} /> : null}
|
|
104
|
+
{cli ? <FeatureBadge label={`CLI:${cli}`} /> : null}
|
|
105
|
+
{i18n ? <FeatureBadge label={`i18n:${i18n}`} /> : null}
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
)
|
|
109
|
+
})}
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
</section>
|
|
113
|
+
|
|
114
|
+
<section className="rounded-lg border bg-card p-4">
|
|
115
|
+
<div className="text-sm font-medium mb-2">{t('app.page.quickLinks.title', 'Quick Links')}</div>
|
|
116
|
+
<div className="flex flex-wrap items-center gap-3 text-sm">
|
|
117
|
+
<Link className="underline hover:text-primary transition-colors" href="/login">{t('app.page.quickLinks.login', 'Login')}</Link>
|
|
118
|
+
<span className="text-muted-foreground">·</span>
|
|
119
|
+
<Link className="underline hover:text-primary transition-colors" href="/example">{t('app.page.quickLinks.examplePage', 'Example Page')}</Link>
|
|
120
|
+
<span className="text-muted-foreground">·</span>
|
|
121
|
+
<Link className="underline hover:text-primary transition-colors" href="/backend/example">{t('app.page.quickLinks.exampleAdmin', 'Example Admin')}</Link>
|
|
122
|
+
<span className="text-muted-foreground">·</span>
|
|
123
|
+
<Link className="underline hover:text-primary transition-colors" href="/backend/todos">{t('app.page.quickLinks.exampleTodos', 'Example Todos with Custom Fields')}</Link>
|
|
124
|
+
<span className="text-muted-foreground">·</span>
|
|
125
|
+
<Link className="underline hover:text-primary transition-colors" href="/blog/123">{t('app.page.quickLinks.exampleBlog', 'Example Blog Post')}</Link>
|
|
126
|
+
</div>
|
|
127
|
+
</section>
|
|
128
|
+
|
|
129
|
+
<footer className="text-xs text-muted-foreground text-center">
|
|
130
|
+
{t('app.page.footer', 'Built with Next.js, MikroORM, and Awilix — modular by design.')}
|
|
131
|
+
</footer>
|
|
132
|
+
</main>
|
|
133
|
+
)
|
|
134
|
+
}
|