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.
Files changed (49) hide show
  1. package/README.md +94 -0
  2. package/bin/create-mercato-app +21 -0
  3. package/dist/index.js +177 -0
  4. package/package.json +42 -0
  5. package/template/.env.example +217 -0
  6. package/template/.yarnrc.yml.template +2 -0
  7. package/template/components.json +22 -0
  8. package/template/gitignore +50 -0
  9. package/template/next.config.ts +28 -0
  10. package/template/package.json.template +87 -0
  11. package/template/postcss.config.mjs +5 -0
  12. package/template/public/catch-the-tornado-logo.png +0 -0
  13. package/template/public/file.svg +1 -0
  14. package/template/public/globe.svg +1 -0
  15. package/template/public/next.svg +1 -0
  16. package/template/public/open-mercato.svg +50 -0
  17. package/template/public/vercel.svg +1 -0
  18. package/template/public/window.svg +1 -0
  19. package/template/src/app/(backend)/backend/[...slug]/page.tsx +59 -0
  20. package/template/src/app/(backend)/backend/layout.tsx +350 -0
  21. package/template/src/app/(backend)/backend/page.tsx +13 -0
  22. package/template/src/app/(frontend)/[...slug]/page.tsx +32 -0
  23. package/template/src/app/api/[...slug]/route.ts +227 -0
  24. package/template/src/app/api/docs/markdown/route.ts +35 -0
  25. package/template/src/app/api/docs/openapi/route.ts +30 -0
  26. package/template/src/app/globals.css +178 -0
  27. package/template/src/app/layout.tsx +76 -0
  28. package/template/src/app/page.tsx +134 -0
  29. package/template/src/bootstrap.ts +58 -0
  30. package/template/src/components/ClientBootstrap.tsx +37 -0
  31. package/template/src/components/GlobalNoticeBars.tsx +116 -0
  32. package/template/src/components/OrganizationSwitcher.tsx +360 -0
  33. package/template/src/components/StartPageContent.tsx +269 -0
  34. package/template/src/components/ui/button.tsx +59 -0
  35. package/template/src/components/ui/card.tsx +92 -0
  36. package/template/src/components/ui/checkbox.tsx +29 -0
  37. package/template/src/components/ui/input.tsx +21 -0
  38. package/template/src/components/ui/label.tsx +24 -0
  39. package/template/src/di.ts +11 -0
  40. package/template/src/i18n/de.json +375 -0
  41. package/template/src/i18n/en.json +376 -0
  42. package/template/src/i18n/es.json +376 -0
  43. package/template/src/i18n/pl.json +375 -0
  44. package/template/src/modules/.gitkeep +0 -0
  45. package/template/src/modules.ts +31 -0
  46. package/template/src/proxy.ts +17 -0
  47. package/template/tsconfig.json +54 -0
  48. package/template/types/pg/index.d.ts +1 -0
  49. 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
+ }