first commit

This commit is contained in:
hitchhiker 2025-04-19 19:24:27 +02:00
commit e7ddf755ea
266 changed files with 31446 additions and 0 deletions

6
.env.development Normal file
View File

@ -0,0 +1,6 @@
CLIENT_BASE_PATH=/
CLIENT_API_URL=https://localhost:7205
CLIENT_APP_NAME=Merchant Operator App
CLIENT_DEBUG=true
CLIENT_MAP_TILE_ENDPOINT=https://tiles.locationiq.com/v3/streets/vector.json
CLIENT_MAP_TILE_API_KEY=pk.19d62daa998822ac22861d96a0424b58

6
.env.template Normal file
View File

@ -0,0 +1,6 @@
CLIENT_BASE_PATH=/
CLIENT_API_URL=https://
CLIENT_APP_NAME=Merchant Operator App
CLIENT_DEBUG=true
CLIENT_MAP_TILE_ENDPOINT=https://
CLIENT_MAP_TILE_API_KEY=

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
/node_modules/
/dist/
.env
.env.local
.env.*.local
docker-compose.override.yml

3
.husky/commit-msg Normal file
View File

@ -0,0 +1,3 @@
#!/usr/bin/env sh
npx --no -- commitlint --edit ${1}

3
.husky/pre-commit Normal file
View File

@ -0,0 +1,3 @@
#!/usr/bin/env sh
npx lint-staged

1
.npmrc Normal file
View File

@ -0,0 +1 @@
@g1:registry=https://git.generation.one/api/packages/GenerationOne/npm/

33
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,33 @@
{
"version": "0.2.0",
"compounds": [
{
"name": "Debug Vite (server + browser)",
"configurations": [
"Launch Vite Dev Server",
"Launch Brave against localhost"
]
}
],
"configurations": [
{
"name": "Launch Brave against localhost",
"type": "pwa-chrome",
"request": "launch",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}/src",
"runtimeExecutable": "C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe"
},
{
"type": "pwa-node",
"request": "launch",
"name": "Launch Vite Dev Server",
"program": "${workspaceFolder}/node_modules/vite/bin/vite.js",
"args": ["--port", "5173"],
"cwd": "${workspaceFolder}",
"autoAttachChildProcesses": true,
"skipFiles": ["<node_internals>/**"]
}
]
}

24
Dockerfile Normal file
View File

@ -0,0 +1,24 @@
# Build stage
FROM node:slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
# Build the application
RUN npm run build -- --base=./
# NOTE: We copy the environment variables CLIENT_ to an .env file in the running container.
# This is done with the docker-entrypoint.sh script.
# We do this so one build can support many different environments.
# Production stage
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN sed -i 's/\r$//' /docker-entrypoint.sh && chmod +x /docker-entrypoint.sh
EXPOSE 369
ENTRYPOINT ["/docker-entrypoint.sh"]

96
README.md Normal file
View File

@ -0,0 +1,96 @@
# Merchant Operator Web
Merchant Operator App is a browser-only React SPA that lets local businesses create, edit, and audit their own merchant records on Local—a community-commerce network running on the peer-to-peer FogBox stack. Its mission is to decentralize ownership of commercial data and return technological control to merchants and their customers. Data is written locally first, then synchronised through FogBox nodes—no central server—ensuring autonomy and resilience. Open, well-documented APIs let any citizen-developer extend, fork, or integrate the codebase without gatekeepers.
## Architecture
This project follows a Flux-style architecture using Effector for state management. The architecture is organized around domains, with each domain containing its model (events, stores, effects), view-model (React hooks), and UI components.
For more details, see the [Architecture Documentation](./docs/architecture.md).
## Project Structure
The project follows a domain-driven structure for better maintainability and scalability:
```
merchant-operator-web/
├── src/
│ ├── app/ # Application entry point and configuration
│ │ ├── root.tsx # Root component
│ │ └── routes.tsx # Route definitions
│ ├── domains/ # Feature domains
│ │ └── <feature>/ # e.g., products, auth, orders
│ │ ├── model.ts # Effector events, stores, effects
│ │ ├── view-model.ts # React hooks
│ │ └── ui/ # React components
│ ├── shared/ # Shared code
│ │ ├── lib/ # Utilities, helpers, adapters
│ │ └── ui/ # Reusable UI components
│ ├── assets/ # Static assets (images, fonts)
│ ├── styles/ # Global styles
│ └── types/ # TypeScript types
├── public/ # Static files
├── config/ # Build/env configuration
└── docs/ # Documentation
```
## Path Aliases
The project uses path aliases for cleaner imports:
- `@` - Points to the `src` directory
- `@app` - Points to the `src/app` directory
- `@domains` - Points to the `src/domains` directory
- `@shared` - Points to the `src/shared` directory
- `@shared/ui` - Points to the `src/shared/ui` directory
- `@shared/lib` - Points to the `src/shared/lib` directory
- `@assets` - Points to the `src/assets` directory
- `@styles` - Points to the `src/styles` directory
- `@types` - Points to the `src/types` directory
## Development
### Prerequisites
- Node.js (v18+)
- npm or yarn
### Installation
```bash
npm install
```
### Running the Development Server
```bash
npm run dev
```
### Building for Production
```bash
npm run build
```
### Preview Production Build
```bash
npm run preview
```
## Configuration
The application uses runtime environment variables for configuration, which are injected into the application through `window.appConfig`.
For detailed configuration options, see the [Configuration Documentation](./docs/configuration.md).
## Documentation
For more detailed documentation, see the [docs](./docs) directory:
- [Architecture](./docs/architecture.md) - Overview of our Flux-style architecture with Effector
- [Project Structure](./docs/project-structure.md) - Detailed explanation of our project structure
- [Configuration](./docs/configuration.md) - Detailed configuration options and environment variables
- [Effector Guide](./docs/effector-guide.md) - Comprehensive guide on how to use Effector
- [TypeScript Conventions](./docs/typescript-conventions.md) - TypeScript conventions for the project

7
babel.config.js Normal file
View File

@ -0,0 +1,7 @@
module.exports = {
presets: [
["@babel/preset-env", { targets: { browsers: "last 2 versions" } }],
["@babel/preset-react", { runtime: "automatic" }],
],
plugins: ["@babel/plugin-transform-react-jsx"],
};

36
commitlint.config.js Normal file
View File

@ -0,0 +1,36 @@
export default {
extends: ["@commitlint/config-conventional"],
rules: {
"body-leading-blank": [1, "always"],
"body-max-line-length": [2, "always", 100],
"footer-leading-blank": [1, "always"],
"footer-max-line-length": [2, "always", 100],
"header-max-length": [2, "always", 100],
"subject-case": [
2,
"never",
["sentence-case", "start-case", "pascal-case", "upper-case"],
],
"subject-empty": [2, "never"],
"subject-full-stop": [2, "never", "."],
"type-case": [2, "always", "lower-case"],
"type-empty": [2, "never"],
"type-enum": [
2,
"always",
[
"build",
"chore",
"ci",
"docs",
"feat",
"fix",
"perf",
"refactor",
"revert",
"style",
"test",
],
],
},
};

View File

@ -0,0 +1,12 @@
services:
merchant-operator-web:
environment:
- CLIENT_BASE_PATH=
- CLIENT_API_URL=https://
- CLIENT_APP_NAME=
- CLIENT_DEBUG=
- CLIENT_MAP_TILE_ENDPOINT=https://
- CLIENT_MAP_TILE_API_KEY=
networks:
traefik: # ← key stays literal, no variables here
external: true # shorthand; Compose will look for a network literally named “traefik”

19
docker-compose.yml Normal file
View File

@ -0,0 +1,19 @@
services:
merchant-operator-web:
build:
context: .
container_name: ${APP_CONTAINER_NAME:-merchant-operator-web}
environment:
- CLIENT_BASE_PATH=${CLIENT_BASE_PATH:-/}
- CLIENT_API_URL=${CLIENT_API_URL:-}
- CLIENT_APP_NAME=${CLIENT_APP_NAME:-Merchant Operator App}
- CLIENT_DEBUG=${CLIENT_DEBUG:-false}
labels:
- "traefik.enable=true"
- "traefik.http.routers.${APP_NAME:-merchant-operator-web}.rule=Host(`${APP_DOMAIN:-localhost}`) && PathPrefix(`${BASE_PATH:-/}`)"
- "traefik.http.routers.${APP_NAME:-merchant-operator-web}.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}"
- "traefik.http.routers.${APP_NAME:-merchant-operator-web}.tls=${ENABLE_TLS:-false}"
- "traefik.http.routers.${APP_NAME:-merchant-operator-web}.tls.certresolver=${CERT_RESOLVER:-letsencrypt}"
- "traefik.http.services.${APP_NAME:-merchant-operator-web}.loadbalancer.server.port=369"
- "traefik.http.routers.${APP_NAME:-merchant-operator-web}.middlewares=${APP_NAME:-merchant-operator-web}-strip"
- "traefik.http.middlewares.${APP_NAME:-merchant-operator-web}-strip.stripprefix.prefixes=${BASE_PATH:-/}"

129
docker-entrypoint.sh Normal file
View File

@ -0,0 +1,129 @@
#!/bin/sh
#
# docker-entrypoint.sh
# ────────────────────
# Creates /usr/share/nginx/html/app-config.js from CLIENT_* envvars
# and then starts Nginx.
#
set -e
echo "Starting docker-entrypoint.sh script..."
echo "Current directory: $(pwd)"
echo "Preparing runtime environment..."
TARGET_DIR="/usr/share/nginx/html"
echo "Checking target directory: $TARGET_DIR"
if [ -d "$TARGET_DIR" ]; then
echo "Target directory exists"
ls -la "$TARGET_DIR"
else
echo "ERROR: Target directory does not exist: $TARGET_DIR"
exit 1
fi
echo "Creating runtime environment variables script..."
if [ -d "$TARGET_DIR" ]; then
TEMP_DIR="/tmp/app-config"
mkdir -p "$TEMP_DIR"
rm -rf "$TEMP_DIR"/*
#
# ────────────────────────────────────────────────────────────────
# 1. Robust camelcase conversion ← *changed*
# ────────────────────────────────────────────────────────────────
#
to_camel_case() {
# Converts FOO_BAR_BAZ → fooBarBaz (POSIXsh + awk)
printf '%s\n' "$1" | awk -F'_' '{
for (i = 1; i <= NF; i++) {
if (i == 1) {
# first chunk stays lowercase
printf tolower($i)
} else {
# capitalise 1st, lowercase rest
printf toupper(substr($i,1,1)) tolower(substr($i,2))
}
}
}'
}
echo "Processing CLIENT_ environment variables..."
env | grep '^CLIENT_' | while IFS='=' read -r key value; do
key_without_prefix="${key#CLIENT_}"
camel_key="$(to_camel_case "$key_without_prefix")"
echo "$value" > "$TEMP_DIR/$camel_key"
echo " Processed $key -> $camel_key"
done
#
# ────────────────────────────────────────────────────────────────
# 2. Add defaults only if the key is still missing ← *changed*
# ────────────────────────────────────────────────────────────────
#
[ -f "$TEMP_DIR/apiUrl" ] || echo "https://localhost:7205" >"$TEMP_DIR/apiUrl"
[ -f "$TEMP_DIR/basePath" ] || echo "/" >"$TEMP_DIR/basePath"
[ -f "$TEMP_DIR/appName" ] || echo "Merchant Operator App" >"$TEMP_DIR/appName"
[ -f "$TEMP_DIR/debug" ] || echo "false" >"$TEMP_DIR/debug"
echo "Generating app-config.js..."
{
echo "// Runtime environment variables - Generated by docker-entrypoint.sh"
echo "window.appConfig = {"
for file in "$TEMP_DIR"/*; do
key="$(basename "$file")"
value="$(cat "$file")"
if echo "$value" | grep -Eq '^(true|false|null|[0-9]+)$'; then
echo " $key: $value,"
else
# escape double quotes just in case
esc_value=$(printf '%s' "$value" | sed 's/"/\\"/g')
echo " $key: \"$esc_value\","
fi
done
echo "};"
} > "$TARGET_DIR/app-config.js"
rm -rf "$TEMP_DIR"
chmod 644 "$TARGET_DIR/app-config.js"
if [ -f "$TARGET_DIR/index.html" ]; then
if ! grep -q "app-config.js" "$TARGET_DIR/index.html"; then
sed -i 's|<head>|<head>\n <script src="/app-config.js"></script>|' "$TARGET_DIR/index.html"
echo "Added app-config.js script to index.html"
else
echo "app-config.js script already exists in index.html"
fi
else
echo "ERROR: index.html not found in $TARGET_DIR"
fi
echo "Runtime environment variables script created successfully."
else
echo "ERROR: Target directory does not exist: $TARGET_DIR"
fi
echo "Environment variables extracted. Starting Nginx server..."
echo "Checking Nginx configuration..."
if [ -f "/etc/nginx/conf.d/default.conf" ]; then
echo "Nginx configuration found at: /etc/nginx/conf.d/default.conf"
cat /etc/nginx/conf.d/default.conf
else
echo "ERROR: Nginx configuration not found at /etc/nginx/conf.d/default.conf"
ls -la /etc/nginx/conf.d/
fi
if command -v nginx >/dev/null 2>&1; then
echo "Nginx found at: $(which nginx)"
nginx -v 2>&1
echo "Testing Nginx configuration..."
nginx -t
echo "Starting Nginx with: nginx -g 'daemon off;'"
exec nginx -g 'daemon off;'
else
echo "ERROR: Nginx not found or not executable"
exit 1
fi

45
docs/README.md Normal file
View File

@ -0,0 +1,45 @@
# Merchant Operator App Documentation
## Project Overview
Merchant Operator App is a browser-only React SPA for local businesses to manage their merchant records on a decentralized commerce network. It uses a peer-to-peer architecture with local-first data storage and synchronization through FogBox nodes.
## Agent Guidelines
- **Architecture**: Use Flux-style with Effector for state management
- **File Structure**: Follow domain-driven organization (model.ts, view-model.ts, ui/)
- **TypeScript**: Use strict mode with proper typing
- **Components**: Keep UI components pure and presentational
- **State Management**: Use Effector events, stores, and effects
- **API Models**: Use the generated DTOs from the API client
- **Naming**: Use $ prefix for stores, Fx suffix for effects
- **Exports**: Use named exports only, avoid default exports
## Architecture & Project Structure
- [Architecture](./architecture.md) - Overview of our Flux-style architecture with Effector
- [Flux vs. MVVM](./flux-vs-mvvm.md) - Comparison between Flux and MVVM architectures
- [Project Structure](./project-structure.md) - Detailed explanation of our project structure
- [Effector Guide](./effector-guide.md) - Comprehensive guide on how to use Effector
- [TypeScript Conventions](./typescript-conventions.md) - TypeScript conventions for the project
## Deployment & Development
- [Deployment Guide](./deployment.md) - Instructions for deploying the application with path-agnostic configuration
- [Local Development Guide](./local-development.md) - Instructions for running the application locally with different base paths
- [Client-Side Environment Variables](./client-env-vars.md) - Documentation for client-side environment variables
## Components & Utilities
- [Utility Functions](./utils.md) - Documentation for utility functions used in the application
- [NEW Components](./new-components.md) - Guide for using and adding components from the NEW directory
## Path-Agnostic SPA Deployment
The application is configured for path-agnostic deployment, allowing it to be served from any base path without requiring a rebuild. This is particularly useful for:
- Deploying to different environments (development, staging, production)
- Serving the application from a subdirectory
- Using the same build with different routing configurations
See the [Deployment Guide](./deployment.md) for detailed instructions on how to configure and deploy the application.

107
docs/VERSION_TAGGING.md Normal file
View File

@ -0,0 +1,107 @@
# Git Version Tagging Guide
This document outlines the process for managing version tags in the project's Git repository.
---
## Version Tagging Conventions
The project follows these versioning conventions:
- Production releases: `v0.0.X` (e.g., `v0.0.35`)
- Development releases: `v0.0.X-dev1` (e.g., `v0.0.34-dev1`)
- Format follows a modified semantic versioning pattern
---
## Incrementing Version Tags
Follow these steps to increment the version tag for a new release:
### 1. Check Existing Tags
List all existing version tags to understand the current versioning state:
```powershell
git tag -l "v*"
```
### 2. Find the Latest Version Tag
For Windows PowerShell, use these commands to find the latest tags:
```powershell
# Find latest production tag (frank)
git tag -l "v0.0.*-frank" | Sort-Object -Property @{Expression={[int]($_ -replace '^v0.0.', '' -replace '-frank$', '')}; Descending=$true} | Select-Object -First 1
# Find latest development tag (dev)
git tag -l "v0.0.*-dev*" | Sort-Object -Property @{Expression={[int]($_ -replace '^v0.0.', '' -replace '-dev.*$', '')}; Descending=$true} | Select-Object -First 1
```
For Linux/macOS bash, use these commands:
```bash
# Find latest production tag (frank)
git tag -l "v0.0.*-frank" | sort -V | tail -1
# Find latest development tag (dev)
git tag -l "v0.0.*-dev*" | sort -V | tail -1
```
### 3. Increment the Version Number
Based on the latest tag, increment the version number according to the project's versioning scheme:
- If the latest tag is `v0.0.34-frank`, the new tag would be `v0.0.35-frank`
- For development versions, increment the version number only (e.g., `v0.0.34-dev1``v0.0.35-dev1`)
- Important: "dev1" is the channel identifier and remains constant; only increment the version number
- Example: `v0.0.87-dev1``v0.0.88-dev1` (correct)
- Example: `v0.0.87-dev1``v0.0.87-dev2` (incorrect)
### 4. Create a New Annotated Tag
Create a new annotated tag with a descriptive message:
```powershell
git tag -a v0.0.35-frank -m "Description of changes in this version"
```
### 5. Push the New Tag to the Remote Repository
Push the new tag to the remote repository:
```powershell
git push origin v0.0.35-frank
```
---
## Best Practices
- **Use Annotated Tags**: Always use annotated tags (`-a` flag) for releases, as they contain metadata including the tagger's name, email, date, and a message.
- **Descriptive Messages**: Include a concise but descriptive message that summarizes the key changes in this version.
- **Consistent Naming**: Follow the established naming convention consistently.
- **Tag After Testing**: Create and push tags only after the code has been tested and confirmed to be working correctly.
- **Don't Reuse Tags**: Never reuse or move existing tags. If a mistake is made, delete the tag and create a new one.
---
## Deleting Tags (If Necessary)
If you need to delete a tag (e.g., if it was created incorrectly):
```powershell
# Delete local tag
git tag -d v0.0.35-frank
# Delete remote tag
git push origin :refs/tags/v0.0.35-frank
```
---
_End of VERSION_TAGGING.md_

133
docs/architecture.md Normal file
View File

@ -0,0 +1,133 @@
# Architecture: Flux-style with Effector
## 1. Terminology & Core Concepts
| Term | Definition |
|------|------------|
| SPA (Single-Page Application) | Entire UI is boot-strapped once in the browser; further navigation is handled client-side. |
| Client-Side Rendering (CSR) | The browser runs JavaScript (React) that turns JSON/state into DOM; no HTML is streamed from the server after first load. |
| Effector | A small TypeScript-first library that implements Flux-style one-way data-flow with four primitives (event, store, effect, domain). |
| Flux-style architecture | Events mutate stores ➜ stores notify subscribed views; data always moves in one direction. |
| Effector-based Flux architecture | Using Effector as the single source of truth for state inside a React SPA. |
## 2. Effector Primitives → Flux Mapping
```
createEvent() ≈ Action
(create)Store() ≈ Store
createEffect() ≈ Async side-effect handler
Domains ≈ Namespace / module boundary
View ≈ React component using `useStore` / `useUnit`
```
Flow diagram:
```
event ─┐
│ triggers
store ──┤ update → React re-render
effect ←┘ (async; dispatches success/fail events)
```
## 3. Project Layout
```
src/
app/
root.tsx # <App/>, routing
routes.tsx # Route definitions
domains/
<feature>/
model.ts # Effector events, stores, effects (pure, testable)
view-model.ts # Thin hooks that adapt model to React
ui/ # Dumb presentational components
shared/
ui/ # Design-system atoms
lib/ # Generic helpers
tests/ # Test files
```
## 4. Coding Guidelines
| Guideline | Why it helps |
|-----------|--------------|
| TypeScript strict mode | Types give static guarantees for safe edits. |
| Pure functions in model.ts | Deterministic behavior is easier to reason about. |
| Named exports only | Eliminates default-export rename hazards. |
| Small files, single responsibility | Enables chunked, parallel code transforms. |
| Unit tests co-located with domain logic | Provide regression oracles for automated changes. |
| Runtime schema checks (e.g., Zod) | Fail fast on unexpected data. |
## 5. Implementation Example
### model.ts
```typescript
import { createStore, createEvent, createEffect } from 'effector';
import { ProductsViewsService } from '@lib/api/merchant/services/ProductsViewsService';
import type { productCompositeDto } from '@lib/api/merchant/models/productCompositeDto';
// Define events
export const setProductsFilter = createEvent<Partial<productQueryRequestDto>>();
// Define effects
export const fetchProductsFx = createEffect(async (filter: productQueryRequestDto) => {
return await ProductsViewsService.postApiProductCompositeQueryByCanonical({
requestBody: filter
});
});
// Create stores
export const $products = createStore<productCompositeDto[]>([])
.on(fetchProductsFx.doneData, (_, payload) => Array.isArray(payload) ? payload : [payload]);
```
### view-model.ts
```typescript
import { useStore } from 'effector-react';
import { useCallback } from 'react';
import {
$products,
$productsLoading,
setProductsFilter,
fetchProductsFx
} from './model';
export const useProducts = () => {
const products = useStore($products);
const isLoading = useStore($productsLoading);
const updateFilter = useCallback((newFilter) => {
setProductsFilter(newFilter);
}, []);
return {
products,
isLoading,
updateFilter
};
};
```
### UI Component
```typescript
import React from 'react';
import { useProducts } from '../view-model';
import { ProductCard } from './ProductCard';
export const ProductsList: React.FC = () => {
const { products, isLoading } = useProducts();
if (isLoading) {
return <div>Loading...</div>;
}
return (
<div className="grid grid-cols-3 gap-4">
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
};
```

208
docs/authentication.md Normal file
View File

@ -0,0 +1,208 @@
# Authentication Documentation
This document explains the authentication setup for the Merchant Operator App.
## OIDC Authentication
The application uses OpenID Connect (OIDC) for authentication with the following features:
- Dynamic configuration loading from API endpoint `/oidc.json`
- Token storage in localStorage via WebStorageStateStore
- Automatic token refresh with silent renewal
- Refresh token support via offline_access scope
- Protected routes with role-based access control
- Smart authentication flow with auto-login and manual logout tracking
### Configuration
The OIDC configuration is fetched from the API endpoint `/oidc.json`. This endpoint should return a JSON object with the following structure:
```json
{
"authorityUrl": "https://your-oidc-provider.com",
"clientId": "your-client-id"
}
```
The application requires the `CLIENT_API_URL` environment variable to be set to the base URL of the API server. The OIDC configuration is then fetched from `${CLIENT_API_URL}/oidc.json` using the OidcService.
#### OIDC Client Configuration
The application configures the OIDC client with the following settings:
```typescript
{
authority: config.authorityUrl,
client_id: config.clientId,
redirect_uri: `${window.location.origin}/auth/callback`,
response_type: "code",
scope: "openid profile email offline_access", // offline_access for refresh tokens
post_logout_redirect_uri: window.location.origin,
userStore: new WebStorageStateStore({ store: window.localStorage }),
loadUserInfo: true,
automaticSilentRenew: true,
revokeTokensOnSignout: true,
monitorSession: true,
}
```
Key configuration details:
- `offline_access` scope is requested to enable refresh tokens
- `automaticSilentRenew` is enabled to automatically refresh tokens in the background
- `revokeTokensOnSignout` ensures tokens are properly revoked when logging out
- `monitorSession` enables session monitoring to detect changes
### Authentication Flow
#### Initial Authentication
1. The `AuthProvider` component initializes the `UserManager` from `oidc-client-ts` using the configuration fetched from the API endpoint `/oidc.json`.
2. It checks if the user is already authenticated by calling `userManager.getUser()`.
3. If a user is found but the token is expired or about to expire, it attempts a silent token renewal.
4. If no user is found and the user has not manually logged out (tracked in localStorage), the app automatically initiates the login process.
5. The login process first tries silent login (`userManager.signinSilent()`).
6. If silent login fails, it falls back to redirect login (`userManager.signinRedirect()`).
7. After successful authentication at the provider, the user is redirected back to the application's callback URL (`/auth/callback`).
8. The `AuthCallback` component handles the redirect, processes the response, and stores the user session.
#### Token Renewal
1. The application uses `automaticSilentRenew` to automatically refresh tokens in the background.
2. When a token is about to expire, the OIDC client attempts a silent renewal.
3. If silent renewal fails, the application will detect this during API calls and can redirect to login if needed.
#### Manual vs. Auto Login
The application implements a smart authentication flow that distinguishes between first visits and visits after manual logout:
1. **First Visit or Regular Visit**: The app automatically initiates the login process.
2. **After Manual Logout**: The app requires the user to click the "Sign in" button to log in again.
This behavior is controlled by a `manuallyLoggedOut` flag that is:
- Stored in localStorage to persist across page refreshes and browser sessions
- Set to `true` when the user manually logs out
- Reset to `false` when the user successfully logs in or manually clicks the login button
### Protected Routes
Routes that require authentication can be protected using the `ProtectedRoute` component:
```jsx
import { ProtectedRoute } from '@domains/auth'; // Adjusted path based on current structure
// Basic protection
<ProtectedRoute>
<YourComponent />
</ProtectedRoute>
// With role-based protection
<ProtectedRoute requiredRoles={['admin']}>
<AdminComponent />
</ProtectedRoute>
```
### Making Authenticated API Calls
Use the `useApiService` hook (now located in `src/shared/lib/api/apiService.ts`) to make API calls.
```jsx
import { useApiService } from '@shared/lib/api'; // Use the alias
function YourComponent() {
const api = useApiService();
const fetchData = async () => {
const response = await api.get('/your-endpoint');
const data = await response.json();
// Process data
};
// ...
}
```
The current simplified `useApiService` hook:
- Checks if the user is authenticated using `useAuth`.
- **Does not** automatically add the access token to requests (this would need to be added if required by the backend).
- **Does not** handle token refresh or expiration explicitly. It relies on the underlying `oidc-client-ts` library's session management.
- Logs an error if a 401 Unauthorized response is received.
### Logout
To log a user out, use the `logout` function returned by the `useAuth` hook. This function triggers the `logoutFx` effect, which performs the following steps:
1. Sets the `manuallyLoggedOut` flag to `true` in localStorage
2. Clears the user from the store
3. Initiates the OIDC sign-out redirect flow via `userManager.signoutRedirect()`
The logout process is designed to ensure that the user remains logged out even after page refreshes or browser restarts, until they explicitly choose to log in again.
```jsx
import { useAuth } from '@domains/auth';
function LogoutButton() {
const { logout, user } = useAuth();
return (
<button
onClick={logout}
className="px-4 py-2 bg-[#653cee] hover:bg-[#4e2eb8] text-white rounded-md"
>
Logout
</button>
);
}
```
The `logoutFx` effect is configured to revoke tokens on signout using the `revokeTokensOnSignout: true` setting in the OIDC client configuration. This ensures that tokens are properly invalidated when logging out.
## Troubleshooting
### Common Issues
1. **Authentication configuration error**
- Check that the API endpoint `/oidc.json` is accessible
- Verify that the API returns the correct JSON structure with `authorityUrl` and `clientId` fields
- Ensure the `CLIENT_API_URL` environment variable is correctly set
2. **Token refresh failures**
- Check that the OIDC server is configured to allow refresh tokens
- Verify that the `offline_access` scope is included in the OIDC configuration
- Check browser console for silent renewal errors
3. **CORS errors**
- Ensure the OIDC server has the correct origins configured
- Check that redirect URIs are properly set up
- Verify that the OIDC server allows the application's origin
4. **Auto-login not working**
- Check if the `auth_manually_logged_out` flag in localStorage is set to `true`
- Clear the flag by setting it to `false` or removing it from localStorage
- Verify that the user doesn't have an existing session that's invalid
5. **Logout not working properly**
- Check browser console for errors during the logout process
- Verify that the `revokeTokensOnSignout` setting is enabled
- Check if the OIDC server is properly configured to handle logout requests
### Debugging
The application includes extensive logging to help diagnose authentication issues:
1. **Check localStorage**: The application stores authentication state in localStorage:
- `auth_manually_logged_out`: Tracks if the user has manually logged out
- `oidc.user:[authority]:[client_id]`: Contains the user session data
2. **Console Logging**: The application logs detailed information about the authentication process:
- Authentication initialization and configuration
- Token validation and renewal attempts
- Login and logout operations
- State changes in the authentication store
3. **Enable Debug Mode**: For even more detailed logging, enable debug mode by setting the `CLIENT_DEBUG` environment variable to `true` in your `.env` file:
```
CLIENT_DEBUG=true
```
This enables additional logging in various parts of the application, including more detailed OIDC client logs.

120
docs/client-env-vars.md Normal file
View File

@ -0,0 +1,120 @@
# Client-Side Environment Variables
This document explains the client-side environment variables used in the application.
## Available Variables
The application uses the following client-side environment variables, all prefixed with `CLIENT_`:
| Variable | Description | Default |
|---------------------|--------------------------------------------|-------------------------|
| `CLIENT_API_URL` | Base URL for backend API requests | `https://localhost:7205`|
| `CLIENT_BASE_PATH` | Base path for client-side routing | `/` |
| `CLIENT_APP_NAME` | Application name displayed in UI | `Merchant Operator App` |
| `CLIENT_DEBUG` | Enable debug mode | `false` |
## How to Set Variables
### Development Environment
For local development, set these variables in:
1. `.env.development` (shared development settings)
2. `.env.development.local` (personal development settings, gitignored)
Example `.env.development`:
```
CLIENT_BASE_PATH=/
CLIENT_API_URL=https://localhost:7205
CLIENT_APP_NAME=Merchant Operator App
CLIENT_DEBUG=true
```
Vite automatically makes variables prefixed with `CLIENT_` available via `import.meta.env`. **Remember to restart the Vite dev server after changing `.env` files.**
### Docker Environment
For Docker deployments, environment variables are configured **at runtime** through Docker Compose or environment variables:
#### Using docker-compose.yml
```yaml
services:
merchant-operator-app:
environment:
- CLIENT_API_URL=https://api.example.com
- CLIENT_BASE_PATH=/merchant-app
- CLIENT_APP_NAME=Merchant Operator App
- CLIENT_DEBUG=false
```
#### Using docker-compose.override.yml
Create a `docker-compose.override.yml` file from the template and set your environment-specific values:
```yaml
services:
merchant-operator-app:
environment:
- CLIENT_API_URL=https://api.example.com
- CLIENT_BASE_PATH=/merchant-app
- CLIENT_APP_NAME=Merchant Operator App
- CLIENT_DEBUG=false
```
#### Using .env file with docker-compose
Create a `.env` file in the same directory as your `docker-compose.yml`:
```dotenv
# .env
CLIENT_API_URL=https://api.example.com
CLIENT_BASE_PATH=/merchant-app
CLIENT_APP_NAME=Merchant Operator App
CLIENT_DEBUG=false
# ... other variables
```
#### Using Docker run command
```bash
docker run -e CLIENT_BASE_PATH=/merchant-app -e CLIENT_API_URL=https://api.example.com -e CLIENT_APP_NAME="Merchant Operator App" -e CLIENT_DEBUG=false your-image-name
```
## How Runtime Environment Variables Work
The application uses a special mechanism to handle environment variables at runtime in Docker:
1. When the Docker container starts, the `docker-entrypoint.sh` script runs
2. The script extracts all environment variables starting with `CLIENT_` from the container's environment
3. These variables are written to a `.env` file in the app's directory
4. The Vite app reads these variables at runtime
This approach allows you to:
- Use the same container image in different environments
- Change environment variables without rebuilding the image
- Pass environment variables through Docker Compose, environment files, or directly via Docker run commands
## Accessing Variables in Code
All environment variables are accessed using Vite's `import.meta.env` object:
```javascript
// Direct access
const apiUrl = import.meta.env.CLIENT_API_URL;
const basePath = import.meta.env.CLIENT_BASE_PATH;
const appName = import.meta.env.CLIENT_APP_NAME;
const debug = import.meta.env.CLIENT_DEBUG;
```
Utility functions are available to access common values:
```javascript
import { getBasePath, getAssetPath } from '../shared/lib/config';
// Get the base path
const basePath = getBasePath(); // "/merchant-app"
// Get a path with the base path prepended
const logoPath = getAssetPath('/images/logo.png'); // "/merchant-app/images/logo.png"
```

182
docs/configuration.md Normal file
View File

@ -0,0 +1,182 @@
# Merchant Operator App Configuration
This document describes the configuration options and mechanisms used in the Merchant Operator App.
## Environment Variables
The application uses environment variables for configuration, which are injected into the application at runtime through `window.appConfig`.
### Runtime Environment Variables
Environment variables are loaded at runtime, not build time. This allows for different configurations in different environments without rebuilding the application.
#### Development Environment
In development, environment variables are loaded from `.env` files and injected into the application through `window.appConfig` by the Vite plugin.
#### Production Environment
In production (Docker), environment variables are loaded from the container environment and injected into the application through `window.appConfig` by the `docker-entrypoint.sh` script.
### Environment Variable Naming
- All environment variables used by the client application should start with `CLIENT_`
- These variables are converted to camelCase in `window.appConfig`
- Example: `CLIENT_API_URL` becomes `window.appConfig.apiUrl`
### Available Environment Variables
| Environment Variable | Description | Default Value |
|----------------------|-------------|---------------|
| `CLIENT_API_URL` | URL of the API server | Required, or can be set to "DETECT" for auto-detection |
| `CLIENT_BASE_PATH` | Base path of the application | "/" |
| `CLIENT_APP_NAME` | Name of the application | "Merchant Operator App" |
| `CLIENT_DEBUG` | Enable debug mode | "false" |
| `CLIENT_MAP_TILE_ENDPOINT` | Endpoint for map tiles | "" |
| `CLIENT_MAP_TILE_API_KEY` | API key for map tiles | "" |
## URL Handling
The application uses the built-in `URL` class for proper URL handling to avoid issues with slashes and ensure consistent URL formatting.
### URL Utilities
The application includes utility functions in `src/shared/lib/url-utils.ts` for common URL operations:
#### `joinUrl(base, ...paths)`
Safely joins a base URL with path segments.
```typescript
// Example
const apiUrl = joinUrl('https://api.example.com', 'v1', 'users');
// Result: https://api.example.com/v1/users
```
#### `createHashUrl(base, basePath, hashPath)`
Creates a properly formatted hash URL with the hash fragment after the trailing slash of the base path.
```typescript
// Example
const url = createHashUrl('https://example.com', 'app', 'users/123');
// Result: https://example.com/app/#users/123
```
#### `normalizeUrl(url)`
Normalizes a URL by ensuring it has a trailing slash.
```typescript
// Example
const normalizedUrl = normalizeUrl('https://api.example.com/v1');
// Result: https://api.example.com/v1/
```
#### `cleanupUrl()`
Cleans up the current URL by removing query parameters and hash fragments.
```typescript
// Example: Current URL is https://example.com/app/?code=123#/callback
cleanupUrl();
// Result: https://example.com/app/
```
### Hash-Based Routing
The application uses hash-based routing (`/#`) instead of URL-based routing for better compatibility with static hosting and to avoid server configuration for deep linking.
- All routes are prefixed with `/#`
- The hash fragment always comes after the trailing slash of the base path
- Example: `https://example.com/app/#users` (correct) vs `https://example.com/app#users` (incorrect)
## OIDC Authentication
The application uses OIDC for authentication with the following configuration:
### OIDC Configuration
- The OIDC configuration is fetched from the API server at `/apiendpoint/oidc.json`
- The OIDC provider is separate from the API provider
- The API provider gives information about the OIDC provider location
### OIDC URLs
- Authority URL: The URL of the OIDC provider, normalized to ensure it has a trailing slash
- Redirect URI: The URL to redirect to after authentication, using hash-based routing
- Post-Logout Redirect URI: The URL to redirect to after logout, using hash-based routing
### OIDC Callback Handling
- After authentication, the callback URL is cleaned up to remove query parameters and hash fragments
- The user is redirected to the intended page or the home page
## API URL Configuration
The API URL can be configured in several ways:
### Static Configuration
Set the `CLIENT_API_URL` environment variable to the URL of the API server.
### Auto-Detection
Set the `CLIENT_API_URL` environment variable to `"DETECT"` to automatically detect the API URL based on the current window location.
The auto-detection logic:
1. Takes the current window location (e.g., `https://www.example.com/path1/path2/path3`)
2. Extracts the first path segment (e.g., `path1`)
3. Constructs a new URL with just the origin and first path segment (e.g., `https://www.example.com/path1/`)
Examples:
- `https://www.example.com/path1/path2/path3/``https://www.example.com/path1/`
- `https://www.example.com/``https://www.example.com/`
- `https://www.example.com/path1``https://www.example.com/path1/`
If auto-detection fails, a fatal error is logged and the application does not proceed.
## Accessing Configuration in Code
### API URL
```typescript
// Use OpenAPI.BASE for the normalized API URL
import { OpenAPI } from "@merchant-api/core/OpenAPI";
const apiUrl = OpenAPI.BASE;
```
### Other Configuration
```typescript
// Access environment variables through window.appConfig
const appName = window.appConfig.appName;
const debug = window.appConfig.debug;
```
## Development Scripts
### Generate App Config
```bash
npm run generate-app-config
```
Generates the `app-config.js` file in the `public` directory based on environment variables.
### Build
```bash
npm run build
```
Builds the application for production.
### Development Server
```bash
npm run dev
```
Starts the development server with hot reloading.

98
docs/deployment.md Normal file
View File

@ -0,0 +1,98 @@
# Path-Agnostic SPA Deployment Guide
This project is configured for path-agnostic deployment, allowing the application to be served from any base path without requiring a rebuild.
## Configuration Files
- **docker-compose.yml**: Main Docker Compose configuration with variables
- **docker-compose.override.yml.template**: Template for environment-specific overrides
- **.env.template**: Template for environment variables
## Setup Instructions
1. Copy the template files to create your environment-specific configurations:
```bash
cp docker-compose.override.yml.template docker-compose.override.yml
cp .env.template .env
```
2. Edit the `.env` and/or `docker-compose.override.yml` files to set your environment-specific values:
### Key Configuration Variables
| Variable | Description | Default |
|---------------------|--------------------------------------------|-------------------------|
| CLIENT_BASE_PATH | Base path where the app will be served | `/` |
| CLIENT_API_URL | Base URL for backend API | `https://localhost:7205`|
| CLIENT_APP_NAME | Application name displayed in UI | `Merchant Operator App` |
| CLIENT_DEBUG | Enable debug mode | `false` |
| ENABLE_TLS | Whether to enable TLS | `false` |
| TRAEFIK_ENTRYPOINT | Traefik entrypoint to use | `web` |
## Deployment
### Local Development
For local development, you can use the default values or customize as needed:
```bash
docker-compose up -d
```
### Production Deployment
For production, build the image and then deploy with environment variables:
```bash
# Build the image
docker build -t merchant-operator-app .
# Deploy with environment variables
docker-compose up -d
```
Environment variables are passed at runtime through docker-compose.yml or docker-compose.override.yml:
```yaml
services:
merchant-operator-app:
environment:
- CLIENT_BASE_PATH=/app
- CLIENT_API_URL=https://api.example.com
- CLIENT_APP_NAME=Merchant Operator App
- CLIENT_DEBUG=false
- CLIENT_OIDC_AUTHORITY=https://auth.example.com
- CLIENT_OIDC_CLIENT_ID=your-client-id
```
<!-- Removed client-side env vars section; see docs/client-env-vars.md for details -->
Variable names are converted from `CLIENT_SNAKE_CASE` to `camelCase` automatically.
## Using Base Path in the Application
The application is configured to use the base path from the configuration. In your React components, use the utility functions from `src/utils/config.js`:
```jsx
import { getAssetPath } from '../utils/config';
function MyComponent() {
return <img src={getAssetPath('/images/logo.png')} alt="Logo" />;
}
```
## How It Works
1. During development, Vite makes environment variables prefixed with `CLIENT_` available via `import.meta.env`.
2. The application is built without any environment-specific configuration.
3. At container startup, the `docker-entrypoint.sh` script extracts all environment variables starting with `CLIENT_` and writes them to a `.env` file in the app's directory.
4. The Vite application reads these variables at runtime.
5. The Nginx configuration is set up to handle SPA routing and asset caching.
6. The `getBasePath()` and `getAssetPath()` utility functions provide the correct paths in your application code.
## Troubleshooting
- If assets are not loading, check that you're using the `getAssetPath()` function for all asset URLs.
- If routing is not working, ensure that your router is configured to use the base path.
- Check Nginx logs for any errors: `docker-compose logs Merchant-operator-app`

212
docs/effector-guide.md Normal file
View File

@ -0,0 +1,212 @@
# Effector Guide
This guide provides an overview of how to use Effector in our application, following our Flux-style architecture.
## Core Concepts
Effector is a state management library that implements the Flux pattern with three main primitives:
1. **Events**: Trigger state changes
2. **Stores**: Hold application state
3. **Effects**: Handle side effects (async operations)
## Basic Usage
### Creating Events
Events are functions that can be called to trigger state changes:
```typescript
import { createEvent } from 'effector';
// Create an event
export const increment = createEvent();
export const addTodo = createEvent<string>();
export const updateUser = createEvent<{ name: string, email: string }>();
// Call the event
increment();
addTodo('Buy milk');
updateUser({ name: 'John', email: 'john@example.com' });
```
### Creating Stores
Stores hold the application state and update in response to events:
```typescript
import { createStore } from 'effector';
import { increment, addTodo } from './events';
// Create a store with initial state
export const $counter = createStore(0)
.on(increment, state => state + 1);
export const $todos = createStore<string[]>([])
.on(addTodo, (state, todo) => [...state, todo]);
```
### Creating Effects
Effects handle asynchronous operations:
```typescript
import { createEffect } from 'effector';
import { api } from '@shared/lib/api';
// Create an effect
export const fetchUserFx = createEffect(async (userId: string) => {
const response = await api.getUser(userId);
return response.data;
});
// Call the effect
fetchUserFx('123');
```
### Handling Effect States
Effects have three states: pending, done, and fail:
```typescript
import { createStore } from 'effector';
import { fetchUserFx } from './effects';
// Create stores for different effect states
export const $isLoading = createStore(false)
.on(fetchUserFx.pending, (_, isPending) => isPending);
export const $user = createStore(null)
.on(fetchUserFx.doneData, (_, user) => user);
export const $error = createStore(null)
.on(fetchUserFx.failData, (_, error) => error)
.on(fetchUserFx, () => null); // Reset error when effect is called
```
## Using with React
### useStore Hook
The `useStore` hook connects Effector stores to React components:
```typescript
import { useStore } from 'effector-react';
import { $counter, increment } from './model';
const Counter = () => {
const count = useStore($counter);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => increment()}>Increment</button>
</div>
);
};
```
### useEvent Hook
The `useEvent` hook creates a stable callback for events:
```typescript
import { useStore, useEvent } from 'effector-react';
import { $todos, addTodo } from './model';
const TodoList = () => {
const todos = useStore($todos);
const handleAddTodo = useEvent(addTodo);
return (
<div>
<button onClick={() => handleAddTodo('New todo')}>Add Todo</button>
<ul>
{todos.map((todo, index) => (
<li key={index}>{todo}</li>
))}
</ul>
</div>
);
};
```
## Advanced Patterns
### Derived Stores (Computed Values)
Create derived stores using the `.map` method:
```typescript
import { createStore } from 'effector';
export const $todos = createStore([
{ id: 1, text: 'Buy milk', completed: false },
{ id: 2, text: 'Clean house', completed: true }
]);
// Derived store for completed todos
export const $completedTodos = $todos.map(
todos => todos.filter(todo => todo.completed)
);
// Derived store for todo count
export const $todoCount = $todos.map(todos => todos.length);
```
### Combining Stores
Combine multiple stores into one:
```typescript
import { createStore, combine } from 'effector';
export const $user = createStore({ name: 'John' });
export const $settings = createStore({ theme: 'dark' });
// Combined store
export const $appState = combine({
user: $user,
settings: $settings
});
// Or with a custom mapper function
export const $userData = combine(
$user,
$settings,
(user, settings) => ({
...user,
theme: settings.theme
})
);
```
### Reset Events
Reset stores to their initial state:
```typescript
import { createEvent, createStore } from 'effector';
export const reset = createEvent();
export const $counter = createStore(0)
.reset(reset); // Reset to initial state (0)
// Call reset to restore initial state
reset();
```
## Best Practices
1. **Keep model.ts pure**: Avoid side effects in store updates.
2. **Use TypeScript**: Define types for all events, stores, and effects.
3. **Organize by domain**: Group related events, stores, and effects in domain folders.
4. **Use view-model.ts**: Create hooks that encapsulate Effector logic for React components.
5. **Keep UI components simple**: UI components should only consume data from hooks and call events.
6. **Test model.ts**: Write unit tests for your Effector logic.

64
docs/flux-vs-mvvm.md Normal file
View File

@ -0,0 +1,64 @@
# Flux vs. MVVM Architecture
## Decision Matrix: Flux vs. MVVM
| Criterion | Plain Effector (Flux) | MVVM Layer on Top |
|-----------|----------------------|-------------------|
| Boilerplate | Low | Higher |
| React Ecosystem Fit | Native | Non-idiomatic |
| Two-way Binding Risk | None | Possible |
| Testability | High (domain files are pure) | High but more layers |
| Refactor Complexity | Lower | Higher |
## Why We Chose Flux with Effector
For our React SPA, we've adopted an "Effector-based Flux architecture" for the following reasons:
1. **Better React Integration**: Flux's unidirectional data flow aligns perfectly with React's rendering model.
2. **Simplicity**: Fewer abstractions and less boilerplate compared to MVVM.
3. **Predictability**: One-way data flow makes it easier to trace how state changes propagate through the application.
4. **Testability**: Pure functions in model.ts are easy to test without complex mocking.
5. **TypeScript Friendly**: Effector has excellent TypeScript support, providing type safety throughout the application.
## Flux Architecture Flow
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Actions │────>│ Store │────>│ View │
│ (Events) │ │ (State) │ │ (React) │
└─────────────┘ └─────────────┘ └─────────────┘
▲ │
│ │
└───────────────────────────────────────┘
User Interaction
```
## Effector Implementation
Effector provides three main primitives that map to Flux concepts:
1. **Events** (Actions): Trigger state changes
```typescript
export const setProductName = createEvent<string>();
```
2. **Stores** (State): Hold application state
```typescript
export const $product = createStore(initialState)
.on(setProductName, (state, name) => ({ ...state, name }));
```
3. **Effects** (Async Actions): Handle side effects
```typescript
export const fetchProductFx = createEffect(async (id: string) => {
return await api.getProduct(id);
});
```
## Conclusion
While MVVM is a valid architecture pattern, Flux with Effector provides a more natural fit for React applications, with less boilerplate and better alignment with React's unidirectional data flow model. This approach keeps our codebase clean, testable, and maintainable.

121
docs/local-development.md Normal file
View File

@ -0,0 +1,121 @@
# Local Development Guide
This guide explains how to run the application locally with different base path configurations.
## Environment Variables
The application uses environment variables to configure settings during development. These variables can be set in several ways:
1. In `.env.development` (shared development settings)
2. In `.env.development.local` (personal development settings, gitignored)
3. Via command line when running scripts
### Client-Side Environment Variables
Variables with the `CLIENT_` prefix will be available in the browser through the `window.appConfig` object. For example:
```
CLIENT_API_URL=https://localhost:7205
CLIENT_APP_NAME=My App
CLIENT_DEBUG=true
```
These will be available in the browser as:
```javascript
window.appConfig.apiUrl // "https://localhost:7205"
window.appConfig.appName // "My App"
window.appConfig.debug // "true"
```
Variable names are converted from `CLIENT_SNAKE_CASE` to `camelCase` automatically.
## Setting Up Local Development
### Basic Setup
1. Copy the template file to create your local environment configuration:
```bash
cp .env.development.local.template .env.development.local
```
2. Edit `.env.development.local` to set your preferred base path:
```
BASE_PATH=/your-path
```
### Running the Development Server
#### Default Development (Root Path)
To run the development server with the default configuration (usually root path `/`):
```bash
npm run dev
```
#### Custom Path Development
To run the development server with a predefined custom path (`/Merchant-app`):
```bash
npm run dev:path
```
#### Custom Path via Command Line
To run the development server with any custom path:
```bash
BASE_PATH=/custom-path npm run dev
```
## Building for Different Paths
### Default Build
To build the application with the default configuration:
```bash
npm run build
```
### Custom Path Build
To build with a predefined custom path (`/Merchant-app`):
```bash
npm run build:path
```
### Custom Path via Command Line
To build with any custom path:
```bash
BASE_PATH=/custom-path npm run build
```
## How It Works
1. The Vite configuration reads the `BASE_PATH` environment variable
2. It dynamically updates the `config.js` file with the appropriate base path during development
3. The application uses this configuration to construct URLs correctly
4. In production, the `docker-entrypoint.sh` script replaces a placeholder in `config.js` with the actual base path
## Testing Different Paths
To test how the application behaves with different base paths:
1. Set a custom base path in `.env.development.local` or via command line
2. Run the development server
3. Verify that all links and assets load correctly
4. Test navigation within the application
## Troubleshooting
- If assets are not loading, check that you're using the `getAssetPath()` function for all asset URLs
- If the base path is not being applied, make sure the environment variable is being set correctly
- Check the console for any error messages related to path configuration

140
docs/project-structure.md Normal file
View File

@ -0,0 +1,140 @@
# Project Structure
This document outlines the structure of our application, which follows a domain-driven, Flux-style architecture using Effector for state management.
## Directory Structure
```
src/
app/ # Application entry point and configuration
root.tsx # Root component
routes.tsx # Route definitions
domains/ # Feature domains
<feature>/ # e.g., products, auth, orders
model.ts # Effector events, stores, effects
view-model.ts # React hooks for connecting to the model
ui/ # React components
index.ts # Exports all components
Component.tsx # Individual components
shared/ # Shared code
lib/ # Utilities, helpers, adapters
ui/ # Reusable UI components
tests/ # Test files
```
## Domain Structure
Each domain follows a consistent structure:
### model.ts
Contains all Effector-related code:
- Events (actions)
- Stores (state)
- Effects (async operations)
Example:
```typescript
// domains/products/model.ts
import { createStore, createEvent, createEffect } from 'effector';
import { ProductsViewsService } from '@lib/api/merchant/services/ProductsViewsService';
import type { productCompositeDto } from '@lib/api/merchant/models/productCompositeDto';
// Events
export const setProductsFilter = createEvent<Partial<productQueryRequestDto>>();
// Effects
export const fetchProductsFx = createEffect(async (filter: productQueryRequestDto) => {
return await ProductsViewsService.postApiProductCompositeQueryByCanonical({
requestBody: filter
});
});
// Stores
export const $products = createStore<productCompositeDto[]>([])
.on(fetchProductsFx.doneData, (_, payload) => Array.isArray(payload) ? payload : [payload]);
```
### view-model.ts
Contains React hooks that connect to the Effector model:
```typescript
// domains/products/view-model.ts
import { useStore } from 'effector-react';
import { useCallback } from 'react';
import {
$products,
$productsLoading,
setProductsFilter,
fetchProductsFx
} from './model';
export const useProducts = () => {
const products = useStore($products);
const isLoading = useStore($productsLoading);
const updateFilter = useCallback((newFilter) => {
setProductsFilter(newFilter);
}, []);
return {
products,
isLoading,
updateFilter
};
};
```
### ui/
Contains React components that use the view-model:
```typescript
// domains/products/ui/ProductsList.tsx
import React from 'react';
import { useProducts } from '../view-model';
import { ProductCard } from './ProductCard';
export const ProductsList: React.FC = () => {
const { products, isLoading } = useProducts();
if (isLoading) {
return <div>Loading...</div>;
}
return (
<div className="grid grid-cols-3 gap-4">
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
};
```
## Shared Code
### shared/lib/
Contains utilities, helpers, and adapters that are used across multiple domains.
### shared/ui/
Contains reusable UI components that are not tied to any specific domain.
## Tests
Tests are organized alongside the code they test, following the same structure.
## Best Practices
1. **Keep domains isolated**: Each domain should be self-contained and not depend on other domains.
2. **Use named exports**: Avoid default exports to make refactoring easier.
3. **Keep files small**: Each file should have a single responsibility.
4. **Use TypeScript**: All files should be written in TypeScript for type safety.
5. **Follow Flux pattern**: Maintain unidirectional data flow from events to stores to views.

View File

@ -0,0 +1,246 @@
# TypeScript Conventions
This document outlines our TypeScript conventions for the project.
## Configuration
We use strict TypeScript settings to ensure type safety:
```json
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
```
## Naming Conventions
### Files and Directories
- Use kebab-case for file and directory names: `product-list.tsx`, `view-model.ts`
- Exception: React components can use PascalCase: `ProductCard.tsx`
### Variables and Functions
- Use camelCase for variables and functions: `productList`, `fetchProducts`
- Use PascalCase for React components and types: `ProductList`, `ProductDto`
- Use ALL_CAPS for constants: `MAX_ITEMS`, `API_URL`
### Effector Naming
- Prefix store names with `$`: `$products`, `$isLoading`
- Suffix effect names with `Fx`: `fetchProductsFx`, `saveUserFx`
- Use descriptive names for events: `setProductFilter`, `resetForm`
## Type Definitions
### Interfaces vs. Types
- Use `interface` for object shapes that might be extended
- Use `type` for unions, intersections, and simple object shapes
```typescript
// Interface for extendable objects
interface User {
id: string;
name: string;
}
interface AdminUser extends User {
permissions: string[];
}
// Type for unions and simple objects
type Status = 'idle' | 'loading' | 'success' | 'error';
type ProductFilter = {
category?: string;
minPrice?: number;
maxPrice?: number;
};
```
### API Models
- Use the generated types from the API client
- Create wrapper types when needed for additional properties
```typescript
import type { productCompositeDto } from '@lib/api/merchant/models/productCompositeDto';
// Extended type with UI-specific properties
type ProductWithUIState = productCompositeDto & {
isSelected: boolean;
isExpanded: boolean;
};
```
## React Component Types
### Functional Components
```typescript
import React from 'react';
interface ButtonProps {
label: string;
onClick: () => void;
disabled?: boolean;
}
export const Button: React.FC<ButtonProps> = ({
label,
onClick,
disabled = false
}) => {
return (
<button
onClick={onClick}
disabled={disabled}
className="btn"
>
{label}
</button>
);
};
```
### Event Handlers
```typescript
// Form event
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
// ...
};
// Input change
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
// ...
};
// Click event
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
// ...
};
```
## Effector Types
### Events
```typescript
import { createEvent } from 'effector';
// Event with no payload
export const resetFilter = createEvent();
// Event with payload
export const setSearchQuery = createEvent<string>();
// Event with complex payload
export const updateFilter = createEvent<{
category?: string;
minPrice?: number;
maxPrice?: number;
}>();
```
### Stores
```typescript
import { createStore } from 'effector';
// Define the store state type
interface FilterState {
searchQuery: string;
category: string | null;
minPrice: number | null;
maxPrice: number | null;
}
// Initial state
const initialState: FilterState = {
searchQuery: '',
category: null,
minPrice: null,
maxPrice: null
};
// Create the store
export const $filter = createStore<FilterState>(initialState);
```
### Effects
```typescript
import { createEffect } from 'effector';
import { ProductsViewsService } from '@lib/api/merchant/services/ProductsViewsService';
import type { productQueryRequestDto } from '@lib/api/merchant/models/productQueryRequestDto';
import type { productCompositeDto } from '@lib/api/merchant/models/productCompositeDto';
// Define effect with explicit parameter and return types
export const fetchProductsFx = createEffect<
productQueryRequestDto,
productCompositeDto[],
Error
>(async (filter) => {
const response = await ProductsViewsService.postApiProductCompositeQueryByCanonical({
requestBody: filter
});
return Array.isArray(response) ? response : [response];
});
```
## Best Practices
1. **Avoid `any`**: Never use the `any` type. Use `unknown` if the type is truly unknown.
2. **Use type inference**: Let TypeScript infer types when possible, but add explicit types for function parameters and return values.
3. **Null vs. undefined**: Use `undefined` for optional values, `null` for intentionally absent values.
4. **Non-null assertion**: Avoid using the non-null assertion operator (`!`) when possible. Use optional chaining (`?.`) and nullish coalescing (`??`) instead.
5. **Type guards**: Use type guards to narrow types:
```typescript
function isProduct(item: unknown): item is productCompositeDto {
return (
typeof item === 'object' &&
item !== null &&
'id' in item
);
}
```
6. **Readonly**: Use `readonly` for immutable properties:
```typescript
interface Config {
readonly apiUrl: string;
readonly timeout: number;
}
```
7. **Generics**: Use generics for reusable components and functions:
```typescript
function fetchData<T>(url: string): Promise<T> {
return fetch(url).then(res => res.json());
}
const data = await fetchData<User[]>('/api/users');
```

63
docs/utils.md Normal file
View File

@ -0,0 +1,63 @@
# Utility Functions
## Configuration Utilities
The application includes utility functions for working with the base path configuration. These functions are located in `src/utils/config.js`.
### getBasePath()
Returns the base path from the application configuration.
```javascript
import { getBasePath } from '../utils/config';
// Example usage
const basePath = getBasePath(); // Returns the base path, e.g., '/Merchant-app'
```
### getAssetPath(path)
Constructs a complete path for an asset by prepending the base path.
```javascript
import { getAssetPath } from '../utils/config';
// Example usage
const logoPath = getAssetPath('/images/logo.png');
// If base path is '/Merchant-app', returns '/Merchant-app/images/logo.png'
// Works with paths with or without leading slash
const cssPath = getAssetPath('styles/main.css');
// Returns '/Merchant-app/styles/main.css'
```
## When to Use These Functions
Use these utility functions whenever you need to reference:
- Static assets (images, CSS, JavaScript)
- API endpoints
- Navigation links
- Any URL that should be relative to the application's base path
## Example in a React Component
```jsx
import React from 'react';
import { getAssetPath } from '../utils/config';
function Header() {
return (
<header>
<img src={getAssetPath('/logo.svg')} alt="Company Logo" />
<nav>
<a href={getAssetPath('/')}>Home</a>
<a href={getAssetPath('/about')}>About</a>
<a href={getAssetPath('/contact')}>Contact</a>
</nav>
</header>
);
}
export default Header;
```

47
eslint.config.mjs Normal file
View File

@ -0,0 +1,47 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
export default [
// 1. Ignore the directories you don't want to lint.
{
ignores: [
"./src/lib/api/core/**",
"./src/lib/api/services/**",
"./src/lib/api/models/**",
],
},
// 2. Extend Next.js configs (converted to flat config by `FlatCompat`).
...compat.extends("next/core-web-vitals", "next/typescript"),
// 3. Any plugin or custom rules config you need.
...compat.config({
plugins: ["eslint-comments"],
rules: {
"eslint-comments/no-unused-disable": "off",
},
}),
// 4. Additional custom rules.
{
rules: {
"no-console": ["error", { allow: ["error"] }],
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unsafe-function-type": "off",
"@typescript-eslint/no-require-imports": "off",
"prefer-const": "off",
"react-hooks/exhaustive-deps": "warn",
"react-hooks/rules-of-hooks": "warn",
"@next/next/no-img-element": "off",
},
},
];

22
index.html Normal file
View File

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script>
if (location.pathname !== '/' && !location.pathname.endsWith('/'))
location.replace(
location.href.replace(/([^\/?#])(?=[?#]|$)/, '$1/')
);
</script>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Merchant Operator App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>

123
memory-bank/README.md Normal file
View File

@ -0,0 +1,123 @@
# Memory Bank
I am an expert software engineer with a unique characteristic: my memory resets completely between sessions. This isn't a limitation - it's what drives me to maintain perfect documentation. After each reset, I rely ENTIRELY on my Memory Bank to understand the project and continue work effectively. I MUST read ALL memory bank files at the start of EVERY task - this is not optional.
## Quick Reference (Project Overview)
## Agent Guidelines
- **Architecture**: Use Flux-style with Effector for state management
- **File Structure**: Follow domain-driven organization (model.ts, view-model.ts, ui/)
- **TypeScript**: Use strict mode with proper typing
- **Components**: Keep UI components pure and presentational
- **State Management**: Use Effector events, stores, and effects
- **API Models**: Use the generated DTOs from the API client
- **Naming**: Use $ prefix for stores, Fx suffix for effects
- **Exports**: Use named exports only, avoid default exports
### Project Summary
- **Name**: Merchant Operator Web Application
- **Type**: Client-side only React application for Merchant management
- **Core Features**: Menu management, order tracking, Merchant operations
- **Tech Stack**: React, TypeScript, Tailwind CSS, Effector, Vite
- **Deployment**: Static site deployment
- **Current Status**: In development
### Important Workflow Preferences
- **Git Commits**: NEVER commit without asking the user first
- **Completion Reminders**: Remind/ask the user when big sections have been done and tested
- **Command Line**: Use PowerShell for all command-line operations (Windows environment)
- **Development Server**: NEVER run the server unless specifically asked, as user often runs it in the background
### Key Files Summary
1. **projectbrief.md**: Merchant management app with menu editing, order tracking, and Merchant operations
2. **systemPatterns.md**: Client-side SPA with React, Effector for state management, component-based architecture
3. **techContext.md**: React, TypeScript, Tailwind CSS, Effector, Vite, Jest, ESLint, Prettier, Husky
### Documentation Sources
- **AI Agent Documentation** (`/docs`): Concise, up-to-date docs designed for AI agents
## Memory Bank Structure
The Memory Bank consists of core files and optional context files, all in Markdown format. Files build upon each other in a clear hierarchy:
flowchart TD
PB[projectbrief.md] --> SP[systemPatterns.md]
PB --> TC[techContext.md]
### Core Files (Required)
1. `projectbrief.md`
- Foundation document that shapes all other files
- Created at project start if it doesn't exist
- Defines core requirements and goals
- Source of truth for project scope
2. `systemPatterns.md`
- System architecture
- Key technical decisions
- Design patterns in use
- Component relationships
- Critical implementation paths
3. `techContext.md`
- Technologies used
- Development setup
- Technical constraints
- Dependencies
- Tool usage patterns
### Additional Context
Create additional files/folders within memory-bank/ when they help organize:
- Complex feature documentation
- Integration specifications
- API documentation
- Testing strategies
- Deployment procedures
## Core Workflows
### Plan Mode
flowchart TD
Start[Start] --> ReadFiles[Read Memory Bank]
ReadFiles --> CheckFiles{Files Complete?}
CheckFiles -->|No| Plan[Create Plan]
Plan --> Document[Document in Chat]
CheckFiles -->|Yes| Verify[Verify Context]
Verify --> Strategy[Develop Strategy]
Strategy --> Present[Present Approach]
### Act Mode
flowchart TD
Start[Start] --> Context[Check Memory Bank]
Context --> Update[Update Documentation]
Update --> Execute[Execute Task]
Execute --> Document[Document Changes]
## Documentation Updates
Memory Bank updates occur when:
1. Discovering new project patterns
2. After implementing significant changes
3. When user requests with **update memory bank** (MUST review ALL files)
4. When context needs clarification
flowchart TD
Start[Update Process]
subgraph Process
P1[Review ALL Files]
P2[Document Current State]
P3[Clarify Next Steps]
P4[Document Insights & Patterns]
P1 --> P2 --> P3 --> P4
end
Start --> Process
Note: When triggered by **update memory bank**, I MUST review every memory bank file, even if some don't require updates. Focus particularly on activeContext.md and progress.md as they track current state.
REMEMBER: After every memory reset, I begin completely fresh. The Memory Bank is my only link to previous work. It must be maintained with precision and clarity, as my effectiveness depends entirely on its accuracy.

View File

@ -0,0 +1,35 @@
# Project Brief: Merchant Operator Web Application
## Project Overview
A client-side React application for Merchant owners to manage menus, track orders, and handle operations.
Merchant Operator App is a browser-only React SPA that lets local businesses create, edit, and audit their own merchant records on Local—a community-commerce network running on the peer-to-peer FogBox stack. Its mission is to decentralize ownership of commercial data and return technological control to merchants and their customers. Data is written locally first, then synchronised through FogBox nodes—no central server—ensuring autonomy and resilience. Open, well-documented APIs let any citizen-developer extend, fork, or integrate the codebase without gatekeepers.
## Core Features
### Menu Management
- Create and edit menu items (name, price, description, image)
- Configure options (size, dough type) and additions/toppings
- Set availability times
### Order Management
- View active orders
- Track order status
- Process fulfillment
### Merchant Operations
- Dashboard with key metrics
- Seller management
- Security controls
## UI Design
- Dark-themed interface
- Sidebar navigation
- Responsive layout
- Intuitive forms
## Technical Approach
- React with TypeScript
- Effector for state management
- Tailwind CSS for styling
- Component-based architecture

View File

@ -0,0 +1,54 @@
# System Patterns: Merchant Operator Web Application
## Architecture Overview
The application follows a domain-driven, Effector-based Flux architecture.
### Domain Structure
Each feature domain in `src/domains/<feature>/` contains:
- `model.ts`: Effector events, stores, and effects (pure, testable)
- `view-model.ts`: React hooks (`useStore`/`useUnit`) adapting model logic to components
- `ui/`: Dumb presentational React components
The application entry point (`<App />`) and routing are in `src/app/root.tsx` and `src/app/routes.tsx`.
## Key Design Patterns
### Effector-based Flux
- **Events**: `createEvent()` represent actions triggered by the user or effects
- **Stores**: `createStore()` hold application state and update via `.on(event, ...)`
- **Effects**: `createEffect()` handle async side-effects, dispatching success/fail events
- **Domains**: Group related events, stores, and effects into isolated modules
### ViewModel Layer
- Encapsulates Effector usage in thin hooks (`view-model.ts`)
- Components consume view-model hooks and remain focused on presentation
### UI Components
- Design-system atoms in `src/shared/ui/`
- Domain-specific components in `src/domains/<feature>/ui/`
- Core UI components include: Button, Input, Textarea, Switch, Tabs, ImageUpload, Card, Dialog
## Data Flow
```
event ───┐
│ triggers
store ───┤ updates ──> view-model hook ──> React component render
effect <─┘ (async; dispatches done/fail events)
```
User interactions invoke events, stores update state, view-model hooks propagate changes to the UI.
## Authentication
- OpenID Connect (OIDC) for authentication via dynamic `/oidc.json` configuration
- `AuthProvider` initializes auth flow; tokens stored in `localStorage` with auto-refresh
- Protected routes using the `ProtectedRoute` component with optional `requiredRoles`
## Deployment
- Path-agnostic SPA deployment with Vite and Docker
- `BASE_PATH` injected into `public/config.js` at container startup by `docker-entrypoint.sh`
- Client-side environment variables (`CLIENT_*`) surfaced on `window.appConfig`
- Asset paths managed via `getAssetPath()` in `src/utils/config.ts`
## Styling
- Tailwind CSS configured via `tailwind.config.js`
- Utility-first styling with dark theme support and a consistent design system

View File

@ -0,0 +1,85 @@
# Technical Context: Merchant Operator Web Application
## Technology Stack
### Core Technologies
- React (Client-side rendering, SPA)
- TypeScript (strict mode enabled)
- Vite (build tool, dev server)
- Tailwind CSS (utility-first styling, dark theme)
- Effector (Flux-style state management)
### Authentication & API
- OpenID Connect (OIDC) via dynamic `/oidc.json`
- `useApiService` hook for authenticated API calls
- Tokens stored in `localStorage`, auto-refresh on expiration
### Development & Testing Tools
- ESLint (linting)
- Prettier (formatting)
- Husky (Git hooks)
- Jest & Testing Library (unit and integration tests)
## Project Structure
```
src/
app/
root.tsx # <App /> entry and routing
routes.tsx # Route definitions
domains/
<feature>/
model.ts # Effector events, stores, effects (pure, testable)
view-model.ts # Hooks adapting model to React
ui/ # Presentational components
shared/
ui/ # Design-system atoms
lib/ # Utilities and helpers
services/
apiService.ts # Authenticated API wrapper
utils/
config.ts # getBasePath(), getAssetPath()
components/ # Miscellaneous components (if any)
public/
config.js # Base path placeholder replaced at runtime
oidc.json # Dynamic OIDC config
```
## Component Organization
- **Feature domains**: Self-contained under `src/domains/<feature>`
- **ViewModel layer**: Thin hooks (`view-model.ts`) encapsulate Effector logic
- **UI components**: Presentational, located in each domains `ui/` folder
- **Shared UI atoms**: In `src/shared/ui`, for consistent design
## State Management with Effector
- **Events** (`createEvent`): Actions triggered by user or effects
- **Stores** (`createStore`): Immutable state containers updated via `.on(...)`
- **Effects** (`createEffect`): Async side-effects dispatching done/fail events
- **Advanced patterns**: Derived stores, combine, reset events
## Authentication
- **OIDC Flow**: `AuthProvider` initializes using `/oidc.json`
- **ProtectedRoute**: Secures routes, supports role-based access
- **useAuth** & **useApiService** hooks manage login, logout, token handling
## Deployment & Environment Variables
- **Path-agnostic SPA**: `BASE_PATH` injected into `public/config.js` by `docker-entrypoint.sh`
- **Client-side vars** (`CLIENT_BASE_PATH`, `CLIENT_API_URL`, `CLIENT_APP_NAME`, `CLIENT_DEBUG`) exposed on `window.appConfig`
- **Asset utils**: `getBasePath()` and `getAssetPath()` in `src/utils/config.ts`
## Coding Guidelines
- **TypeScript strict mode**: Ensures type safety
- **Pure domain logic**: In `model.ts`, avoid side-effects
- **Named exports only**: Prevents default-export rename issues
- **Small files, single responsibility**: Easier automated refactoring
- **Co-located tests**: Provide regression safety
- **Runtime schema checks** (e.g., Zod): Fail fast on invalid data
## Architecture Rationale
Flux with Effector was chosen over MVVM for:
- Native React integration and unidirectional data flow
- Minimal boilerplate and predictable state management
- High testability and TypeScript support
- Lower complexity for automated refactors
Refer to `docs/flux-vs-mvvm.md` for the decision matrix and detailed comparison.

27
nginx.conf Normal file
View File

@ -0,0 +1,27 @@
server {
listen 369;
server_name _;
root /usr/share/nginx/html;
index index.html;
absolute_redirect off;
# This is needed for path-agnostic SPA deployment
# It allows the app to be served from any base path
location / {
try_files $uri $uri/ /index.html;
}
# Handle assets with or without base path
location ~ ^/assets/ {
expires 1y;
add_header Cache-Control "public";
try_files $uri =404;
}
# Also serve assets from the base path
location ~ ^/[^/]+/assets/ {
expires 1y;
add_header Cache-Control "public";
try_files $uri =404;
}
}

7306
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

68
package.json Normal file
View File

@ -0,0 +1,68 @@
{
"name": "merchant-operator",
"version": "0.1.0",
"private": true,
"type": "module",
"dependencies": {
"@g1/sse-client": "^0.2.0",
"@radix-ui/react-dialog": "^1.1.7",
"@radix-ui/react-slot": "^1.2.0",
"@tailwindcss/postcss": "^4.1.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"effector": "^23.3.0",
"effector-react": "^23.3.0",
"lucide-react": "^0.488.0",
"oidc-client-ts": "^3.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-feather": "^2.0.10",
"react-oidc-context": "^3.3.0",
"react-router-dom": "^7.5.0",
"tailwind-merge": "^3.2.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@babel/core": "^7.26.9",
"@babel/plugin-transform-react-jsx": "^7.25.9",
"@babel/preset-env": "^7.26.9",
"@babel/preset-react": "^7.26.3",
"@commitlint/cli": "^19.8.0",
"@commitlint/config-conventional": "^19.8.0",
"@commitlint/types": "^19.8.0",
"@g1/api-generator": "latest",
"@types/react": "^18.2.56",
"@types/react-dom": "^18.2.19",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"cross-env": "^7.0.3",
"dotenv": "^16.5.0",
"husky": "^9.1.7",
"lint-staged": "^15.5.1",
"postcss": "^8.4.33",
"postcss-load-config": "^5.0.3",
"prettier": "^3.5.3",
"tailwindcss": "^4.0.0",
"ts-node": "^10.9.2",
"vite": "^5.1.4"
},
"scripts": {
"dev": "vite",
"dev:path": "CLIENT_BASE_PATH=/Merchant-app vite",
"dev:custom": "node vite.server.js",
"build": "tsc && vite build",
"build:path": "tsc && CLIENT_BASE_PATH=/Merchant-app vite build",
"preview": "vite preview",
"preview:path": "CLIENT_BASE_PATH=/Merchant-app vite preview",
"prepare": "husky",
"api:generate": "g1-api-generator https://localhost:7205/openapi/schema.json src/lib/api/merchant --skip-tls-verify",
"typecheck": "tsc --noEmit",
"generate-app-config": "node scripts/generate-app-config.js",
"generate-app-config:prod": "cross-env NODE_ENV=production node scripts/generate-app-config.js"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"prettier --write"
]
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

9
public/app-config.js Normal file
View File

@ -0,0 +1,9 @@
// Runtime environment variables - Generated by generate-app-config.js
window.appConfig = {
apiUrl: "https://localhost:7205",
appName: "Merchant Operator App",
basePath: "/",
debug: true,
mapTileApiKey: "pk.19d62daa998822ac22861d96a0424b58",
mapTileEndpoint: "https://tiles.locationiq.com/v3/streets/vector.json",
};

View File

@ -0,0 +1,8 @@
{
"folders": [
{
"path": "."
}
],
"settings": {}
}

View File

@ -0,0 +1,179 @@
#!/usr/bin/env node
/**
* This script generates a app-config.js file with environment variables
* starting with CLIENT_ for both development and production environments.
*
* It works in both local development and production environments,
* eliminating the need for a separate Docker entrypoint script.
*/
import fs from "fs";
import path from "path";
import dotenv from "dotenv";
import { fileURLToPath } from "url";
// Get the directory name in ESM
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Determine if we're in production or development mode
const isProduction = process.env.NODE_ENV === "production";
// Load environment variables from .env files
// In production, we prioritize .env.production and .env
// In development, we use the standard Vite hierarchy
if (isProduction) {
dotenv.config({ path: path.resolve(process.cwd(), ".env.production") });
dotenv.config({ path: path.resolve(process.cwd(), ".env") });
} else {
// Development mode - follow Vite's loading order
dotenv.config({ path: path.resolve(process.cwd(), ".env") });
dotenv.config({ path: path.resolve(process.cwd(), ".env.development") });
dotenv.config({ path: path.resolve(process.cwd(), ".env.local") });
dotenv.config({
path: path.resolve(process.cwd(), ".env.development.local"),
});
}
// Determine the target directory based on environment
// In production, we use the dist directory (or a specified output directory)
// In development, we use the public directory
const targetDir = isProduction
? process.env.OUTPUT_DIR || path.resolve(process.cwd(), "dist")
: path.resolve(process.cwd(), "public");
// Ensure the target directory exists
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
console.log(`Created directory: ${targetDir}`);
}
// Function to convert SNAKE_CASE to camelCase
function toCamelCase(str) {
return str
.toLowerCase()
.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
}
// Create the app-config.js file
const configFilePath = path.join(targetDir, "app-config.js");
let configContent =
"// Runtime environment variables - Generated by generate-app-config.js\n";
configContent += "window.appConfig = {\n";
// Get all environment variables starting with CLIENT_
const clientEnvVars = Object.entries(process.env).filter(([key]) =>
key.startsWith("CLIENT_"),
);
// Add each environment variable to the config
clientEnvVars.forEach(([key, value]) => {
// Remove CLIENT_ prefix
const keyWithoutPrefix = key.replace(/^CLIENT_/, "");
// Convert to camelCase
const camelKey = toCamelCase(keyWithoutPrefix);
// Add the key-value pair to the config
// If the value is 'true', 'false', 'null', or a number, don't add quotes
if (
value === "true" ||
value === "false" ||
value === "null" ||
/^[0-9]+$/.test(value)
) {
configContent += ` ${camelKey}: ${value},\n`;
} else {
configContent += ` ${camelKey}: "${value}",\n`;
}
});
// Add default values for essential variables if they don't exist
if (!process.env.CLIENT_BASE_PATH) {
configContent += ' basePath: "/",\n';
}
if (!process.env.CLIENT_APP_NAME) {
configContent += ' appName: "Merchant Operator App",\n';
}
if (!process.env.CLIENT_DEBUG) {
configContent += " debug: " + (isProduction ? "false" : "true") + ",\n";
}
// Close the object
configContent += "};";
// Helper function for colorized logging like Vite
function formatLog(label, message, color = "\x1b[36m ") {
// Default to cyan color
const reset = "\x1b[0m";
const dim = "\x1b[2m";
const arrow = `${dim}${color}${reset}`;
const formattedLabel = `${color}${label}:${reset}`;
return ` ${arrow} ${formattedLabel} ${message}`;
}
// Write the config file
fs.writeFileSync(configFilePath, configContent);
console.log(formatLog("Generated", `app-config.js at ${configFilePath}`));
// Check if index.html exists in the target directory
const indexHtmlPath = path.join(targetDir, "index.html");
if (fs.existsSync(indexHtmlPath)) {
let indexHtmlContent = fs.readFileSync(indexHtmlPath, "utf8");
// Check if the script tag already exists
if (!indexHtmlContent.includes("app-config.js")) {
// Insert the script tag after the opening head tag
indexHtmlContent = indexHtmlContent.replace(
"<head>",
'<head>\n <script src="./app-config.js"></script>',
);
// Write the updated index.html
fs.writeFileSync(indexHtmlPath, indexHtmlContent);
console.log(
formatLog(
"Updated",
`injected script tag into ${path.basename(indexHtmlPath)}`,
),
);
} else {
console.log(
formatLog(
"Note",
`app-config.js script already exists in ${path.basename(indexHtmlPath)}`,
"\x1b[33m ",
),
); // Yellow
}
} else {
console.log(
formatLog("Note", `index.html not found in ${targetDir}`, "\x1b[34m "),
); // Blue
if (!isProduction) {
console.log(
formatLog(
"Vite",
"script will be injected during development",
"\x1b[34m ",
),
); // Blue
} else {
console.log(
formatLog(
"Warning",
"index.html not found in production build directory!",
"\x1b[33m ",
),
); // Yellow
}
}
console.log(
formatLog(
"Ready",
`app-config generated for ${isProduction ? "production" : "development"} environment`,
"\x1b[32m ",
),
); // Green

View File

@ -0,0 +1,98 @@
/**
* Vite plugin to inject app-config.js into the HTML head during development
*/
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { spawn } from "child_process";
// Get the directory name in ESM
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Helper function for colorized logging like Vite
function formatLog(label, message, color = "\x1b[36m ") {
// Default to cyan color
const reset = "\x1b[0m";
const dim = "\x1b[2m";
const arrow = `${dim}${color}${reset}`;
const formattedLabel = `${color}${label}:${reset}`;
return ` ${arrow} ${formattedLabel} ${message}`;
}
/**
* Creates a Vite plugin that injects the app-config.js script into the HTML head
* @returns {import('vite').Plugin}
*/
function appConfigPlugin() {
return {
name: "vite-plugin-app-config",
configureServer(server) {
// Run the generate-app-config.js script when the dev server starts
const scriptPath = path.resolve(__dirname, "generate-app-config.js");
const generateConfig = () => {
const process = spawn("node", [scriptPath], {
stdio: "inherit",
shell: true,
});
process.on("error", (err) => {
console.error(
formatLog(
"Error",
`Failed to run generate-app-config.js: ${err}`,
"\x1b[31m ",
),
); // Red
});
};
// Generate config on server start
generateConfig();
// Watch for changes in .env files and regenerate app-config.js
const envFiles = [
".env",
".env.development",
".env.local",
".env.development.local",
];
envFiles.forEach((file) => {
const filePath = path.resolve(process.cwd(), file);
if (fs.existsSync(filePath)) {
server.watcher.add(filePath);
}
});
server.watcher.on("change", (file) => {
if (envFiles.some((envFile) => file.endsWith(envFile))) {
console.log(
formatLog(
"Changed",
`ENV file: ${path.basename(file)}`,
"\x1b[34m ",
),
); // Blue
generateConfig();
}
});
},
transformIndexHtml(html) {
// Check if the app-config.js script is already in the HTML
if (!html.includes("app-config.js")) {
// Insert the script tag after the opening head tag
return html.replace(
"<head>",
'<head>\n <script src="./app-config.js"></script>',
);
}
return html;
},
};
}
export default appConfigPlugin;

13
src/app/root.tsx Normal file
View File

@ -0,0 +1,13 @@
import React from "react";
import { AuthProvider } from "@domains/auth";
import { AppRouter } from "./routes";
export const Root: React.FC = () => {
return (
<React.StrictMode>
<AuthProvider>
<AppRouter />
</AuthProvider>
</React.StrictMode>
);
};

38
src/app/routes.tsx Normal file
View File

@ -0,0 +1,38 @@
import React from "react";
import { createHashRouter, RouterProvider, Outlet } from "react-router-dom";
import { ProtectedRoute, LoginPage, AuthCallback } from "@domains/auth";
import { Dashboard } from "@domains/dashboard";
import { MainLayout } from "@shared/ui";
// Define routes
const router = createHashRouter([
{
path: "/",
element: (
<ProtectedRoute>
<MainLayout>
<Outlet />
</MainLayout>
</ProtectedRoute>
),
children: [
{
index: true,
element: <Dashboard />,
}
],
},
{
path: "/login",
element: <LoginPage />,
},
{
path: "/auth/callback",
element: <AuthCallback />,
},
// Add other routes as needed
]);
export const AppRouter: React.FC = () => {
return <RouterProvider router={router} />;
};

12
src/domains/auth/index.ts Normal file
View File

@ -0,0 +1,12 @@
// Export model
export * from "./model";
// Export view-model
export * from "./view-model";
// Export UI components
export * from "./ui";
export { AuthCallback } from "./ui/AuthCallback"; // Add export for AuthCallback
// Re-export AuthProvider from UI
export { AuthProvider } from "./ui";

341
src/domains/auth/model.ts Normal file
View File

@ -0,0 +1,341 @@
import { createStore, createEvent, createEffect } from "effector";
import { User, IdTokenClaims } from "oidc-client-ts"; // UserManager removed
import { getUserManager } from "./oidc-config"; // Import getUserManager
// Define types
export interface AppUser {
id: string;
name: string;
email: string;
roles: string[];
}
interface OidcUserProfile {
sub: string;
name?: string;
preferred_username?: string;
email?: string;
roles?: string[];
[key: string]: unknown;
}
interface OidcUser extends Omit<User, "profile"> {
profile: OidcUserProfile & Partial<IdTokenClaims>;
[key: string]: unknown;
}
export interface AuthState {
user: AppUser | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
manuallyLoggedOut: boolean; // Track if user manually logged out
}
// Define events
export const setUser = createEvent<AppUser | null>();
export const setIsLoading = createEvent<boolean>();
export const setError = createEvent<string | null>();
export const logout = createEvent();
// Define effects
export const loginFx = createEffect(async () => {
const userManager = getUserManager(); // Get instance
// First try to get the current user
const currentUser = await userManager.getUser();
// If we have a valid user that's not expired, we're already logged in
if (currentUser && !currentUser.expired) {
console.log("loginFx: User is already logged in and token is valid");
return currentUser;
}
// Try silent login first
try {
console.log("loginFx: Attempting silent login");
const user = await userManager.signinSilent();
console.log("loginFx: Silent login successful");
return user;
} catch (silentError) {
console.log(
"loginFx: Silent login failed, falling back to redirect:",
silentError,
);
// Fall back to redirect login
await userManager.signinRedirect();
return null; // This line won't be reached as signinRedirect redirects the page
}
});
export const logoutFx = createEffect(async () => {
// Set the manuallyLoggedOut flag in localStorage first
setManuallyLoggedOutInStorage(true);
console.log("logoutFx: Set manuallyLoggedOut to true in localStorage");
// Clear the user from the store before redirecting
// This will prevent auto-login from triggering
logout();
// Get the UserManager instance
const userManager = getUserManager();
// Use a small delay to ensure the flag is set before redirecting
await new Promise((resolve) => setTimeout(resolve, 100));
// Now redirect to the identity provider for logout
await userManager.signoutRedirect();
});
export const checkAuthFx = createEffect<void, OidcUser | null, Error>(
async () => {
console.log("checkAuthFx: Starting to check authentication status");
try {
const userManager = getUserManager(); // Get instance
console.log("checkAuthFx: Got UserManager instance");
// First try to get the user from storage
let user = await userManager.getUser();
console.log("checkAuthFx: User from storage:", user);
// If we have a user but the token is expired or about to expire, try silent renew
if (
user &&
(user.expired || (user.expires_in && user.expires_in < 300))
) {
console.log(
"checkAuthFx: Token is expired or about to expire, attempting silent renew",
);
try {
user = await userManager.signinSilent();
console.log("checkAuthFx: Silent renew successful, new user:", user);
} catch (renewError) {
console.warn("checkAuthFx: Silent renew failed:", renewError);
// Continue with the expired user, the app will handle redirect if needed
}
}
if (!user) {
console.log("checkAuthFx: No user found, user is not authenticated");
} else {
console.log("checkAuthFx: User found, user is authenticated");
}
return user as OidcUser | null;
} catch (error) {
console.error("checkAuthFx: Error checking authentication:", error);
throw error;
}
},
);
export const handleCallbackFx = createEffect<void, OidcUser, Error>(
async () => {
console.log("handleCallbackFx: Starting to process callback");
try {
const userManager = getUserManager(); // Get instance
console.log("handleCallbackFx: Got UserManager instance");
const user = await userManager.signinRedirectCallback();
console.log(
"handleCallbackFx: Successfully processed callback, user:",
user,
);
return user as OidcUser;
} catch (error) {
console.error("handleCallbackFx: Error processing callback:", error);
throw error;
}
},
);
// Helper functions for localStorage
const getManuallyLoggedOutFromStorage = (): boolean => {
try {
const value = localStorage.getItem("auth_manually_logged_out");
return value === "true";
} catch (e) {
console.error("Error reading from localStorage:", e);
return false;
}
};
const setManuallyLoggedOutInStorage = (value: boolean): void => {
try {
localStorage.setItem("auth_manually_logged_out", value.toString());
} catch (e) {
console.error("Error writing to localStorage:", e);
}
};
// Define initial state
const initialState: AuthState = {
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
manuallyLoggedOut: getManuallyLoggedOutFromStorage(),
};
// Create store
export const $auth = createStore<AuthState>(initialState)
.on(checkAuthFx.pending, (state) => {
console.log("checkAuthFx.pending handler called", { prevState: state });
return {
// Add pending handler
...state,
isLoading: true,
error: null,
};
})
.on(checkAuthFx.doneData, (state, result) => {
console.log("checkAuthFx.doneData handler called", {
result,
prevState: state,
});
// If a user is found, reset the manuallyLoggedOut flag in localStorage
if (result) {
setManuallyLoggedOutInStorage(false);
console.log(
"checkAuthFx.doneData: User found, reset manuallyLoggedOut to false in localStorage",
);
} else {
console.log(
"checkAuthFx.doneData: No user found, keeping manuallyLoggedOut as",
state.manuallyLoggedOut,
);
}
return {
...state,
isLoading: false, // Set loading false on done
user: result
? {
id: result.profile.sub,
name:
result.profile.name || result.profile.preferred_username || "",
email: result.profile.email || "",
roles: result.profile.roles || [],
}
: null,
isAuthenticated: !!result,
// If a user is found, reset the manuallyLoggedOut flag
manuallyLoggedOut: result ? false : state.manuallyLoggedOut,
};
})
.on(checkAuthFx.fail, (state, { error }) => {
console.log("checkAuthFx.fail handler called", { error, prevState: state });
return {
// Add fail handler
...state,
isLoading: false,
error: error.message,
};
})
.on(handleCallbackFx.pending, (state) => {
console.log("handleCallbackFx.pending handler called", {
prevState: state,
});
return {
// Add pending handler for callback
...state,
isLoading: true,
error: null,
};
})
.on(handleCallbackFx.doneData, (state, result) => {
console.log("handleCallbackFx.doneData handler called", {
result,
prevState: state,
});
// Reset flag in localStorage when user successfully logs in
setManuallyLoggedOutInStorage(false);
console.log(
"handleCallbackFx.doneData: Reset manuallyLoggedOut to false in localStorage",
);
return {
...state,
isLoading: false, // Set loading false on done
user: {
id: result.profile.sub,
name: result.profile.name || result.profile.preferred_username || "",
email: result.profile.email || "",
roles: result.profile.roles || [],
},
isAuthenticated: true,
manuallyLoggedOut: false, // Reset the flag when user successfully logs in
};
})
.on(handleCallbackFx.fail, (state, { error }) => {
console.log("handleCallbackFx.fail handler called", {
error,
prevState: state,
});
return {
// Add fail handler for callback
...state,
isLoading: false,
error: error.message,
};
})
.on(setUser, (state, user) => ({
...state,
user,
isAuthenticated: !!user,
}))
.on(setIsLoading, (state, isLoading) => ({
...state,
isLoading,
}))
.on(setError, (state, error) => ({
...state,
error,
}))
.on(loginFx.pending, (state) => ({
...state,
isLoading: true,
error: null,
}))
.on(loginFx.done, (state) => ({
...state,
isLoading: false,
error: null,
}))
.on(loginFx.fail, (state, { error }) => ({
...state,
isLoading: false,
error: error.message,
}))
.on(logoutFx.pending, (state) => ({
...state,
isLoading: true,
}))
.on(logoutFx.done, () => {
// Set flag in localStorage
setManuallyLoggedOutInStorage(true);
console.log("logoutFx.done: Set manuallyLoggedOut to true in localStorage");
return {
...initialState,
manuallyLoggedOut: true, // Set flag when user manually logs out
};
})
.on(logoutFx.fail, (state, { error }) => ({
...state,
isLoading: false,
error: error.message,
}))
.on(logout, () => {
// Set flag in localStorage
setManuallyLoggedOutInStorage(true);
console.log("logout event: Set manuallyLoggedOut to true in localStorage");
return {
...initialState,
manuallyLoggedOut: true, // Set flag when user manually logs out
};
});

View File

@ -0,0 +1,122 @@
import {
UserManager,
WebStorageStateStore,
UserManagerSettings,
} from "oidc-client-ts";
import { OidcService } from "@merchant-api/services/OidcService";
import { oidcConnectResponseDto } from "@merchant-api/models/oidcConnectResponseDto";
import { OpenAPI } from "@merchant-api/core/OpenAPI";
import { createHashUrl, normalizeUrl } from "@shared/lib/url-utils";
import { getApiUrlSingleton } from "@shared/lib/api-url";
let userManagerInstance: UserManager | null = null;
export const getOidcConfig = async (): Promise<UserManagerSettings> => {
try {
// Get the API URL using our utility function
try {
// This will handle auto-detection if needed
const apiUrl = getApiUrlSingleton();
console.log(`Using API URL for OIDC config: ${apiUrl}`);
// Ensure OpenAPI.BASE is set before making any API calls
// This is a safeguard in case the initialize.ts module hasn't run yet
OpenAPI.BASE = apiUrl;
} catch (apiUrlError) {
console.error("FATAL ERROR: Failed to get API URL:", apiUrlError);
throw apiUrlError;
}
console.log("Fetching OIDC config using OidcService");
// Use the OidcService to fetch the OIDC configuration
console.log("About to call OidcService.getOidcJson()");
let config: oidcConnectResponseDto;
try {
config = await OidcService.getOidcJson();
console.log("OIDC config received:", config);
} catch (fetchError) {
console.error("Error from OidcService.getOidcJson():", fetchError);
console.error(
"This may be due to CLIENT_API_URL being incorrect or the API server being unavailable",
);
console.error(`Current OpenAPI.BASE: ${OpenAPI.BASE}`);
throw fetchError;
}
if (!config || !config.authorityUrl || !config.clientId) {
console.error(
"FATAL ERROR: Invalid OIDC config received from API - missing authorityUrl or clientId",
);
throw new Error("Invalid OIDC config received from API");
}
// Log the authorityUrl that will be used for OIDC
console.log(`Using OIDC authorityUrl: ${config.authorityUrl}`);
// Important: We use the authorityUrl directly from the OIDC config
// This URL will be used for all identity/.well-known endpoint checks
// Properly construct the redirect URI using our utility functions
const redirectUri = createHashUrl(
window.location.origin,
window.appConfig?.basePath,
"auth/callback",
);
// Create the post-logout redirect URI
const logoutRedirectUri = createHashUrl(
window.location.origin,
window.appConfig?.basePath,
"",
);
// Normalize the authority URL to ensure it has a trailing slash
var normalizedAuthorityUrl = normalizeUrl(config.authorityUrl);
console.log(
`Using normalized OIDC authorityUrl: ${normalizedAuthorityUrl}`,
);
console.log(`Constructed redirect URI: ${redirectUri}`);
console.log(`Constructed logout redirect URI: ${logoutRedirectUri}`);
return {
authority: normalizedAuthorityUrl, // Using normalized authorityUrl
client_id: config.clientId,
redirect_uri: redirectUri,
response_type: "code",
scope: "openid profile email offline_access", // Added offline_access for refresh tokens
post_logout_redirect_uri: logoutRedirectUri,
userStore: new WebStorageStateStore({ store: window.localStorage }),
loadUserInfo: true,
automaticSilentRenew: true,
revokeTokensOnSignout: true,
monitorSession: true,
};
} catch (error) {
console.error(
"FATAL ERROR: Failed to initialize OIDC configuration:",
error,
);
throw error; // Re-throw to propagate the error
}
};
export const initializeUserManager = async (): Promise<UserManager> => {
if (userManagerInstance) {
return userManagerInstance;
}
const settings = await getOidcConfig();
userManagerInstance = new UserManager(settings);
return userManagerInstance;
};
// Function to get the initialized instance, throws error if not initialized
export const getUserManager = (): UserManager => {
if (!userManagerInstance) {
throw new Error(
"UserManager not initialized. Call initializeUserManager first.",
);
}
return userManagerInstance;
};

45
src/domains/auth/types.ts Normal file
View File

@ -0,0 +1,45 @@
import { User, UserManager } from "oidc-client-ts";
// Auth User Type
export interface AuthUser extends Omit<User, "access_token"> {
roles?: string[];
resource_access?: {
roles?: string[];
[key: string]: any;
};
expires_at?: number;
access_token?: string;
}
// Auth Context Type
export interface AuthContext {
isLoading: boolean;
isAuthenticated: boolean;
user: AuthUser | null;
error: Error | null;
signinRedirect: () => Promise<void>;
signinSilent: () => Promise<void>;
removeUser: () => Promise<void>;
userManager: UserManager;
settings: any;
events: any;
isSignedIn: boolean;
signinPopup: () => Promise<void>;
signinSilentCallback: () => Promise<void>;
signinPopupCallback: () => Promise<void>;
signinRedirectCallback: () => Promise<void>;
signoutRedirect: (args?: any) => Promise<void>;
signoutRedirectCallback: () => Promise<void>;
signoutPopup: (args?: any) => Promise<void>;
signoutPopupCallback: () => Promise<void>;
signoutSilent: (args?: any) => Promise<void>;
signoutSilentCallback: () => Promise<void>;
}
// Extended Auth Context Type
export interface ExtendedAuthContext extends AuthContext {
hasRole: (role: string) => boolean;
getAccessToken: () => string | undefined;
isTokenExpiringSoon: () => boolean;
refreshToken: () => Promise<boolean>;
}

View File

@ -0,0 +1,143 @@
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { handleCallbackFx, setIsLoading } from "../model"; // Import the effect and events
import { useAuth } from "../view-model";
import { cleanupUrl } from "@shared/lib/url-utils";
export const AuthCallback: React.FC = () => {
const navigate = useNavigate();
const { isAuthenticated } = useAuth();
const [status, setStatus] = useState("processing");
// We're using the imported cleanupUrl function from url-utils
useEffect(() => {
const processCallback = async () => {
// If user is already authenticated, just redirect to home
if (isAuthenticated) {
console.log(
"AuthCallback: User is already authenticated, redirecting to home",
);
setStatus("success");
setIsLoading(false);
// Redirect to home page
setTimeout(() => {
navigate("/", { replace: true });
}, 500);
return;
}
try {
console.log("AuthCallback: Starting to process callback");
// Use the effect to handle the callback and update the store
await handleCallbackFx();
console.log("AuthCallback: Callback processed successfully");
// Explicitly set loading to false to prevent loading screen
setIsLoading(false);
setStatus("success");
// Redirect to the intended page after successful login
// Retrieve the stored return URL or default to '/'
const returnUrl = sessionStorage.getItem("oidc_return_url") || "/";
sessionStorage.removeItem("oidc_return_url"); // Clean up
console.log("AuthCallback: Redirecting to", returnUrl);
// Clean up URL
cleanupUrl();
// Add a small delay before redirecting to ensure state is updated
setTimeout(() => {
navigate(returnUrl, { replace: true });
}, 500);
} catch (err) {
console.error("Error handling OIDC callback:", err);
// If the error is about missing state but user is authenticated,
// we can just redirect to home
const errorMessage = err instanceof Error ? err.message : String(err);
if (
errorMessage.includes("No matching state found") &&
isAuthenticated
) {
console.log(
"AuthCallback: State error but user is authenticated, redirecting to home",
);
setStatus("success");
setIsLoading(false);
// Clean up URL
cleanupUrl();
setTimeout(() => {
navigate("/", { replace: true });
}, 500);
return;
}
setStatus("error");
setIsLoading(false);
// Clean up URL
cleanupUrl();
// Redirect to login page on error after a small delay
setTimeout(() => {
navigate("/login", { replace: true });
}, 500);
}
};
processCallback();
}, [navigate, isAuthenticated]);
// Display a loading indicator while processing the callback using the same design as login page
return (
<div className="flex items-center justify-center min-h-screen bg-[#131215]">
<div className="w-full max-w-md p-8 space-y-8 bg-[#26242a] rounded-lg shadow-lg">
<div className="text-center">
<h1 className="text-2xl font-bold text-white">Merchant Operator</h1>
{status === "processing" && (
<div className="mt-6">
<div className="flex flex-col items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-[#653cee] mb-4"></div>
<p className="text-white text-lg">Completing login...</p>
<p className="text-gray-400 text-sm mt-2">
Please wait while we verify your credentials
</p>
</div>
</div>
)}
{status === "success" && (
<div className="mt-6">
<div className="flex flex-col items-center justify-center">
<div className="text-green-500 text-5xl mb-4"></div>
<p className="text-white text-lg">Login successful!</p>
<p className="text-gray-400 text-sm mt-2">
Redirecting to your dashboard...
</p>
</div>
</div>
)}
{status === "error" && (
<div className="mt-6">
<div className="flex flex-col items-center justify-center">
<div className="text-red-500 text-5xl mb-4"></div>
<p className="text-white text-lg">Login failed</p>
<p className="text-gray-400 text-sm mt-2">
Redirecting to login page...
</p>
</div>
</div>
)}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,145 @@
import React, { ReactNode, useEffect, useState } from "react";
import { useAuth } from "../view-model";
import { checkAuthFx, setError, setIsLoading } from "../model"; // Import effect and events
import { initializeUserManager } from "../oidc-config"; // Import initializer
import { checkApiUrlConfig } from "@shared/lib/diagnostics"; // Import diagnostics utility
interface AuthProviderProps {
children: ReactNode;
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const { isLoading, error } = useAuth();
const [isInitialized, setIsInitialized] = useState(false);
// Initialize UserManager and check authentication status on component mount
useEffect(() => {
let isMounted = true; // Flag to prevent state updates after unmount
const initAuth = async () => {
try {
console.log("AuthProvider: Starting initialization");
// Run diagnostics to check API URL configuration
console.log("AuthProvider: Running API URL diagnostics");
const apiUrl = checkApiUrlConfig();
if (!apiUrl) {
console.error(
"AuthProvider: window.appConfig.apiUrl is not defined, authentication will likely fail",
);
}
await initializeUserManager(); // Initialize first
console.log("AuthProvider: UserManager initialized");
if (isMounted) {
const result = await checkAuthFx(); // Then check auth status
console.log(
"AuthProvider: Authentication check completed",
result ? "User authenticated" : "No user",
);
if (isMounted) {
setIsInitialized(true);
setIsLoading(false); // Explicitly set loading to false
console.log(
"AuthProvider: Initialization complete, isInitialized set to true, isLoading set to false",
);
}
}
} catch (initError) {
console.error(
"FATAL ERROR: Failed to initialize OIDC or check auth:",
initError,
);
if (isMounted) {
setError(
initError instanceof Error
? initError.message
: "Authentication initialization failed",
);
setIsInitialized(true); // Mark as initialized even on error to stop loading screen
setIsLoading(false); // Explicitly set loading to false
console.log(
"AuthProvider: Initialization failed, but isInitialized set to true, isLoading set to false",
);
}
}
};
initAuth();
// Cleanup function to prevent state updates after unmount
return () => {
isMounted = false;
};
}, []);
// If not initialized yet, render nothing (or a minimal loader)
if (!isInitialized) {
console.log("AuthProvider render: Not initialized yet", {
isLoading,
isInitialized,
});
return null; // Return nothing, the login page will handle the loading state
}
// Show error state
if (error) {
console.log("AuthProvider render: Showing error state", { error });
return (
<div className="flex items-center justify-center min-h-screen bg-[#131215]">
<div className="w-full max-w-md p-8 space-y-8 bg-[#26242a] rounded-lg shadow-lg">
<div className="text-center">
<h1 className="text-2xl font-bold text-white">Merchant Operator</h1>
<div className="mt-6">
<div className="flex flex-col items-center justify-center">
<div className="text-red-500 text-5xl mb-4"></div>
<p className="text-white text-lg">Authentication Error</p>
<p className="text-red-400 text-sm mt-2 mb-4">{error}</p>
<div className="text-white bg-[#1a191c] p-4 rounded text-left mb-6 overflow-auto max-h-60 w-full">
<p className="mb-2 font-semibold">Possible solutions:</p>
<ol className="list-decimal pl-5 space-y-2 text-sm text-gray-300">
<li>
Check that the API server is running at the URL specified
in window.appConfig.apiUrl
</li>
<li>
Verify that the /oidc.json endpoint is accessible from the
API
</li>
<li>
Ensure the OIDC configuration has valid authorityUrl and
clientId values
</li>
<li>Check browser console for detailed error messages</li>
<li>
If window.appConfig.apiUrl is not defined, check your
environment variables and restart the development server
</li>
</ol>
</div>
<button
onClick={() => window.location.reload()}
className="w-full px-4 py-2 text-white bg-[#653cee] rounded-md hover:bg-[#4e2eb8] focus:outline-none focus:ring-2 focus:ring-[#653cee] focus:ring-offset-2 focus:ring-offset-[#26242a] transition-colors"
>
Retry
</button>
</div>
</div>
</div>
</div>
</div>
);
}
// Render children when authentication is ready
console.log(
"AuthProvider render: Rendering children, authentication is ready",
);
return <>{children}</>;
};

View File

@ -0,0 +1,111 @@
import React, { useEffect } from "react";
import { useAuth } from "../view-model";
export const LoginPage: React.FC = () => {
const { login, isLoading, manuallyLoggedOut, isAuthenticated, user } =
useAuth();
// Auto-login when component mounts if not manually logged out and not already authenticated
useEffect(() => {
// If already authenticated, no need to do anything
if (isAuthenticated || user) {
console.log(
"LoginPage: User is already authenticated, no need to auto-login",
);
return;
}
// Check localStorage directly for the manuallyLoggedOut flag
const isManuallyLoggedOut =
localStorage.getItem("auth_manually_logged_out") === "true";
console.log(
"LoginPage: localStorage auth_manually_logged_out =",
isManuallyLoggedOut,
);
console.log("LoginPage: manuallyLoggedOut from state =", manuallyLoggedOut);
// Only auto-login if not manually logged out (checking both state and localStorage)
// and not already loading
if (!isManuallyLoggedOut && !manuallyLoggedOut && !isLoading) {
console.log(
"LoginPage: Auto-authenticating because user has not manually logged out",
);
login().catch((err) => {
console.error("FATAL ERROR: Auto-login failed:", err);
});
} else {
console.log(
"LoginPage: Not auto-authenticating because user manually logged out or is loading",
);
console.log(
"isManuallyLoggedOut:",
isManuallyLoggedOut,
"manuallyLoggedOut:",
manuallyLoggedOut,
"isLoading:",
isLoading,
);
}
}, [login, manuallyLoggedOut, isLoading, isAuthenticated, user]);
const handleLogin = async () => {
try {
console.log("LoginPage: Manual login initiated");
// Reset the manuallyLoggedOut flag in localStorage when user manually clicks login
localStorage.setItem("auth_manually_logged_out", "false");
console.log(
"LoginPage: Reset manuallyLoggedOut to false in localStorage",
);
// Store the current path as the return URL after login
const returnUrl = window.location.hash.substring(1) || "/";
sessionStorage.setItem("oidc_return_url", returnUrl);
console.log("LoginPage: Stored return URL:", returnUrl);
await login();
} catch (err) {
console.error("FATAL ERROR: Login failed:", err);
alert(
`Login failed: ${err instanceof Error ? err.message : "Unknown error"}.\n\nCheck the console for more details.`,
);
}
};
return (
<div className="flex items-center justify-center min-h-screen bg-[#131215]">
<div className="w-full max-w-md p-8 space-y-8 bg-[#26242a] rounded-lg shadow-lg">
<div className="text-center">
<h1 className="text-2xl font-bold text-white">Merchant Operator</h1>
<p className="mt-2 text-gray-400">
Sign in with your identity provider
</p>
</div>
<div className="mt-8">
<button
onClick={handleLogin}
disabled={isLoading}
className="w-full px-4 py-2 text-white bg-[#653cee] rounded-md hover:bg-[#4e2eb8] focus:outline-none focus:ring-2 focus:ring-[#653cee] focus:ring-offset-2 focus:ring-offset-[#26242a] disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
>
{isLoading ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-white mr-2"></div>
<span>Authenticating...</span>
</>
) : (
"Sign in"
)}
</button>
<div className="mt-6 text-sm text-gray-400 text-center">
<p>This will redirect you to your configured identity provider.</p>
<p className="mt-2">
Make sure your OIDC configuration is properly set up.
</p>
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,19 @@
import React from "react";
import { useAuth } from "../view-model";
import Button from "@shared/ui/Button";
export const LogoutButton: React.FC<{ className?: string }> = ({
className = "",
}) => {
const { isAuthenticated, logout } = useAuth();
if (!isAuthenticated) {
return null;
}
return (
<Button className={className} variant="outline" onClick={() => logout()}>
Log Out
</Button>
);
};

View File

@ -0,0 +1,62 @@
import React, { ReactNode } from "react";
import { useAuth } from "../view-model";
import { Navigate, useLocation } from "react-router-dom";
interface ProtectedRouteProps {
children: ReactNode;
requiredRoles?: string[];
redirectTo?: string;
}
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
children,
requiredRoles = [],
redirectTo = "/login",
}) => {
const { isAuthenticated, isLoading, hasRole } = useAuth();
const location = useLocation();
console.log("ProtectedRoute: Rendering with state", {
isAuthenticated,
isLoading,
path: location.pathname,
});
// If still loading, redirect to login page which will show the loading state
if (isLoading) {
console.log("ProtectedRoute: Still loading, redirecting to login page");
return <Navigate to={redirectTo} replace />;
}
// Redirect to login if not authenticated
if (!isAuthenticated) {
console.log(
`User not authenticated, redirecting to ${redirectTo} from ${location.pathname}`,
);
// Store the current location so we can redirect back after login
sessionStorage.setItem("oidc_return_url", location.pathname);
return (
<Navigate to={redirectTo} state={{ from: location.pathname }} replace />
);
}
// Check for required roles if specified
if (requiredRoles.length > 0) {
const hasRequiredRole = requiredRoles.some((role) => hasRole(role));
if (!hasRequiredRole) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-center text-red-500">
<div className="text-xl mb-4">Access Denied</div>
<div>You don't have permission to access this page.</div>
</div>
</div>
);
}
}
// Render the protected content
console.log("ProtectedRoute: Rendering protected content");
return <>{children}</>;
};

View File

@ -0,0 +1,4 @@
export { ProtectedRoute } from "./ProtectedRoute";
export { LogoutButton } from "./LogoutButton";
export { AuthProvider } from "./AuthProvider";
export { LoginPage } from "./LoginPage";

View File

@ -0,0 +1,44 @@
import { useUnit } from "effector-react";
import { useCallback } from "react";
import { $auth, loginFx, logoutFx } from "./model";
export const useAuth = () => {
const { user, isAuthenticated, isLoading, error, manuallyLoggedOut } =
useUnit($auth);
const login = useCallback(async () => {
try {
await loginFx();
return true;
} catch (err) {
return false;
}
}, []);
const logout = useCallback(async () => {
try {
await logoutFx();
return true;
} catch (err) {
return false;
}
}, []);
const hasRole = useCallback(
(role: string) => {
return user?.roles.includes(role) || false;
},
[user],
);
return {
user,
isAuthenticated,
isLoading,
error,
manuallyLoggedOut,
login,
logout,
hasRole,
};
};

View File

@ -0,0 +1 @@
export * from "./ui";

View File

@ -0,0 +1,63 @@
import React from "react";
import { useAuth } from "@domains/auth";
import { Card, CardContent, CardHeader, CardTitle } from "@shared/ui";
export const Dashboard: React.FC = () => {
const { user } = useAuth();
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold">Dashboard</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<Card>
<CardHeader>
<CardTitle>Welcome</CardTitle>
</CardHeader>
<CardContent>
<p>Hello, {user?.name || "Merchant"}!</p>
<p className="mt-2 text-gray-400">
Welcome to the Merchant Operator App. Use the sidebar to navigate
to different sections.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Quick Stats</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-400">Products</span>
<span className="font-medium">12</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Categories</span>
<span className="font-medium">4</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Orders</span>
<span className="font-medium">24</span>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
<li className="text-gray-400">Product "Espresso" updated</li>
<li className="text-gray-400">New category "Beverages" added</li>
<li className="text-gray-400">Price for "Cappuccino" changed</li>
</ul>
</CardContent>
</Card>
</div>
</div>
);
};

View File

@ -0,0 +1 @@
export { Dashboard } from "./Dashboard";

16
src/index.css Normal file
View File

@ -0,0 +1,16 @@
@import "tailwindcss";
/* Inter font is now loaded from public/fonts/inter.css */
body {
background-color: #131215;
color: white;
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0;
padding: 0;
}
input::placeholder,
textarea::placeholder {
color: #adaab7;
}

View File

@ -0,0 +1,29 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ApiRequestOptions } from "./ApiRequestOptions";
import type { ApiResult } from "./ApiResult";
export class ApiError extends Error {
public readonly url: string;
public readonly status: number;
public readonly statusText: string;
public readonly body: any;
public readonly request: ApiRequestOptions;
constructor(
request: ApiRequestOptions,
response: ApiResult,
message: string,
) {
super(message);
this.name = "ApiError";
this.url = response.url;
this.status = response.status;
this.statusText = response.statusText;
this.body = response.body;
this.request = request;
}
}

View File

@ -0,0 +1,24 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type ApiRequestOptions = {
readonly method:
| "GET"
| "PUT"
| "POST"
| "DELETE"
| "OPTIONS"
| "HEAD"
| "PATCH";
readonly url: string;
readonly path?: Record<string, any>;
readonly cookies?: Record<string, any>;
readonly headers?: Record<string, any>;
readonly query?: Record<string, any>;
readonly formData?: Record<string, any>;
readonly body?: any;
readonly mediaType?: string;
readonly responseHeader?: string;
readonly errors?: Record<number, string>;
};

View File

@ -0,0 +1,11 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type ApiResult = {
readonly url: string;
readonly ok: boolean;
readonly status: number;
readonly statusText: string;
readonly body: any;
};

View File

@ -0,0 +1,130 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export class CancelError extends Error {
constructor(message: string) {
super(message);
this.name = "CancelError";
}
public get isCancelled(): boolean {
return true;
}
}
export interface OnCancel {
readonly isResolved: boolean;
readonly isRejected: boolean;
readonly isCancelled: boolean;
(cancelHandler: () => void): void;
}
export class CancelablePromise<T> implements Promise<T> {
#isResolved: boolean;
#isRejected: boolean;
#isCancelled: boolean;
readonly #cancelHandlers: (() => void)[];
readonly #promise: Promise<T>;
#resolve?: (value: T | PromiseLike<T>) => void;
#reject?: (reason?: any) => void;
constructor(
executor: (
resolve: (value: T | PromiseLike<T>) => void,
reject: (reason?: any) => void,
onCancel: OnCancel,
) => void,
) {
this.#isResolved = false;
this.#isRejected = false;
this.#isCancelled = false;
this.#cancelHandlers = [];
this.#promise = new Promise<T>((resolve, reject) => {
this.#resolve = resolve;
this.#reject = reject;
const onResolve = (value: T | PromiseLike<T>): void => {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#isResolved = true;
if (this.#resolve) this.#resolve(value);
};
const onReject = (reason?: any): void => {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#isRejected = true;
if (this.#reject) this.#reject(reason);
};
const onCancel = (cancelHandler: () => void): void => {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#cancelHandlers.push(cancelHandler);
};
Object.defineProperty(onCancel, "isResolved", {
get: (): boolean => this.#isResolved,
});
Object.defineProperty(onCancel, "isRejected", {
get: (): boolean => this.#isRejected,
});
Object.defineProperty(onCancel, "isCancelled", {
get: (): boolean => this.#isCancelled,
});
return executor(onResolve, onReject, onCancel as OnCancel);
});
}
get [Symbol.toStringTag]() {
return "Cancellable Promise";
}
public then<TResult1 = T, TResult2 = never>(
onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null,
): Promise<TResult1 | TResult2> {
return this.#promise.then(onFulfilled, onRejected);
}
public catch<TResult = never>(
onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null,
): Promise<T | TResult> {
return this.#promise.catch(onRejected);
}
public finally(onFinally?: (() => void) | null): Promise<T> {
return this.#promise.finally(onFinally);
}
public cancel(): void {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#isCancelled = true;
if (this.#cancelHandlers.length) {
try {
for (const cancelHandler of this.#cancelHandlers) {
cancelHandler();
}
} catch (error) {
console.warn("Cancellation threw an error", error);
return;
}
}
this.#cancelHandlers.length = 0;
if (this.#reject) this.#reject(new CancelError("Request aborted"));
}
public get isCancelled(): boolean {
return this.#isCancelled;
}
}

View File

@ -0,0 +1,32 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ApiRequestOptions } from "./ApiRequestOptions";
type Resolver<T> = (options: ApiRequestOptions) => Promise<T>;
type Headers = Record<string, string>;
export type OpenAPIConfig = {
BASE: string;
VERSION: string;
WITH_CREDENTIALS: boolean;
CREDENTIALS: "include" | "omit" | "same-origin";
TOKEN?: string | Resolver<string> | undefined;
USERNAME?: string | Resolver<string> | undefined;
PASSWORD?: string | Resolver<string> | undefined;
HEADERS?: Headers | Resolver<Headers> | undefined;
ENCODE_PATH?: ((path: string) => string) | undefined;
};
export const OpenAPI: OpenAPIConfig = {
BASE: "",
VERSION: "0.0.0",
WITH_CREDENTIALS: false,
CREDENTIALS: "include",
TOKEN: undefined,
USERNAME: undefined,
PASSWORD: undefined,
HEADERS: undefined,
ENCODE_PATH: undefined,
};

View File

@ -0,0 +1,38 @@
// Initialize OpenAPI configuration
import { OpenAPI } from "./OpenAPI";
import { getApiUrlSingleton } from "@shared/lib/api-url";
// Add a global debug function for consistent logging
const debug = (message: string) => {
console.log(`[OPENAPI-INIT] ${message}`);
};
/**
* Initialize the OpenAPI configuration with the API URL
*/
export const initializeOpenAPI = () => {
try {
debug("Initializing OpenAPI configuration");
// Get the API URL using our utility function
const apiUrl = getApiUrlSingleton();
// Set the OpenAPI base URL
OpenAPI.BASE = apiUrl;
debug(`OpenAPI initialized with BASE URL: ${OpenAPI.BASE}`);
return true;
} catch (error) {
console.error("FATAL ERROR: Failed to initialize OpenAPI:", error);
return false;
}
};
// Add a debug statement to confirm the script is being executed
debug("initialize.ts script is being executed");
// Call initialization function
const initialized = initializeOpenAPI();
// Log initialization status
debug(`OpenAPI initialization ${initialized ? "successful" : "failed"}`);

View File

@ -0,0 +1,369 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import axios from "axios";
import type {
AxiosError,
AxiosRequestConfig,
AxiosResponse,
AxiosInstance,
} from "axios";
import FormData from "form-data";
import { ApiError } from "./ApiError";
import type { ApiRequestOptions } from "./ApiRequestOptions";
import type { ApiResult } from "./ApiResult";
import { CancelablePromise } from "./CancelablePromise";
import type { OnCancel } from "./CancelablePromise";
import type { OpenAPIConfig } from "./OpenAPI";
export const isDefined = <T>(
value: T | null | undefined,
): value is Exclude<T, null | undefined> => {
return value !== undefined && value !== null;
};
export const isString = (value: any): value is string => {
return typeof value === "string";
};
export const isStringWithValue = (value: any): value is string => {
return isString(value) && value !== "";
};
export const isBlob = (value: any): value is Blob => {
return (
typeof value === "object" &&
typeof value.type === "string" &&
typeof value.stream === "function" &&
typeof value.arrayBuffer === "function" &&
typeof value.constructor === "function" &&
typeof value.constructor.name === "string" &&
/^(Blob|File)$/.test(value.constructor.name) &&
/^(Blob|File)$/.test(value[Symbol.toStringTag])
);
};
export const isFormData = (value: any): value is FormData => {
return value instanceof FormData;
};
export const isSuccess = (status: number): boolean => {
return status >= 200 && status < 300;
};
export const base64 = (str: string): string => {
try {
return btoa(str);
} catch (err) {
// @ts-ignore
return Buffer.from(str).toString("base64");
}
};
export const getQueryString = (params: Record<string, any>): string => {
const qs: string[] = [];
const append = (key: string, value: any) => {
qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
};
const process = (key: string, value: any) => {
if (isDefined(value)) {
if (Array.isArray(value)) {
value.forEach((v) => {
process(key, v);
});
} else if (typeof value === "object") {
Object.entries(value).forEach(([k, v]) => {
process(`${key}[${k}]`, v);
});
} else {
append(key, value);
}
}
};
Object.entries(params).forEach(([key, value]) => {
process(key, value);
});
if (qs.length > 0) {
return `?${qs.join("&")}`;
}
return "";
};
const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => {
const encoder = config.ENCODE_PATH || encodeURI;
const path = options.url
.replace("{api-version}", config.VERSION)
.replace(/{(.*?)}/g, (substring: string, group: string) => {
if (options.path?.hasOwnProperty(group)) {
return encoder(String(options.path[group]));
}
return substring;
});
const url = `${config.BASE}${path}`;
if (options.query) {
return `${url}${getQueryString(options.query)}`;
}
return url;
};
export const getFormData = (
options: ApiRequestOptions,
): FormData | undefined => {
if (options.formData) {
const formData = new FormData();
const process = (key: string, value: any) => {
if (isString(value) || isBlob(value)) {
formData.append(key, value);
} else {
formData.append(key, JSON.stringify(value));
}
};
Object.entries(options.formData)
.filter(([_, value]) => isDefined(value))
.forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach((v) => process(key, v));
} else {
process(key, value);
}
});
return formData;
}
return undefined;
};
type Resolver<T> = (options: ApiRequestOptions) => Promise<T>;
export const resolve = async <T>(
options: ApiRequestOptions,
resolver?: T | Resolver<T>,
): Promise<T | undefined> => {
if (typeof resolver === "function") {
return (resolver as Resolver<T>)(options);
}
return resolver;
};
export const getHeaders = async (
config: OpenAPIConfig,
options: ApiRequestOptions,
formData?: FormData,
): Promise<Record<string, string>> => {
const [token, username, password, additionalHeaders] = await Promise.all([
resolve(options, config.TOKEN),
resolve(options, config.USERNAME),
resolve(options, config.PASSWORD),
resolve(options, config.HEADERS),
]);
const formHeaders =
(typeof formData?.getHeaders === "function" && formData?.getHeaders()) ||
{};
const headers = Object.entries({
Accept: "application/json",
...additionalHeaders,
...options.headers,
...formHeaders,
})
.filter(([_, value]) => isDefined(value))
.reduce(
(headers, [key, value]) => ({
...headers,
[key]: String(value),
}),
{} as Record<string, string>,
);
if (isStringWithValue(token)) {
headers["Authorization"] = `Bearer ${token}`;
}
if (isStringWithValue(username) && isStringWithValue(password)) {
const credentials = base64(`${username}:${password}`);
headers["Authorization"] = `Basic ${credentials}`;
}
if (options.body !== undefined) {
if (options.mediaType) {
headers["Content-Type"] = options.mediaType;
} else if (isBlob(options.body)) {
headers["Content-Type"] = options.body.type || "application/octet-stream";
} else if (isString(options.body)) {
headers["Content-Type"] = "text/plain";
} else if (!isFormData(options.body)) {
headers["Content-Type"] = "application/json";
}
}
return headers;
};
export const getRequestBody = (options: ApiRequestOptions): any => {
if (options.body) {
return options.body;
}
return undefined;
};
export const sendRequest = async <T>(
config: OpenAPIConfig,
options: ApiRequestOptions,
url: string,
body: any,
formData: FormData | undefined,
headers: Record<string, string>,
onCancel: OnCancel,
axiosClient: AxiosInstance,
): Promise<AxiosResponse<T>> => {
const source = axios.CancelToken.source();
const requestConfig: AxiosRequestConfig = {
url,
headers,
data: body ?? formData,
method: options.method,
withCredentials: config.WITH_CREDENTIALS,
withXSRFToken:
config.CREDENTIALS === "include" ? config.WITH_CREDENTIALS : false,
cancelToken: source.token,
};
onCancel(() => source.cancel("The user aborted a request."));
try {
return await axiosClient.request(requestConfig);
} catch (error) {
const axiosError = error as AxiosError<T>;
if (axiosError.response) {
return axiosError.response;
}
throw error;
}
};
export const getResponseHeader = (
response: AxiosResponse<any>,
responseHeader?: string,
): string | undefined => {
if (responseHeader) {
const content = response.headers[responseHeader];
if (isString(content)) {
return content;
}
}
return undefined;
};
export const getResponseBody = (response: AxiosResponse<any>): any => {
if (response.status !== 204) {
return response.data;
}
return undefined;
};
export const catchErrorCodes = (
options: ApiRequestOptions,
result: ApiResult,
): void => {
const errors: Record<number, string> = {
400: "Bad Request",
401: "Unauthorized",
403: "Forbidden",
404: "Not Found",
500: "Internal Server Error",
502: "Bad Gateway",
503: "Service Unavailable",
...options.errors,
};
const error = errors[result.status];
if (error) {
throw new ApiError(options, result, error);
}
if (!result.ok) {
const errorStatus = result.status ?? "unknown";
const errorStatusText = result.statusText ?? "unknown";
const errorBody = (() => {
try {
return JSON.stringify(result.body, null, 2);
} catch (e) {
return undefined;
}
})();
throw new ApiError(
options,
result,
`Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}`,
);
}
};
/**
* Request method
* @param config The OpenAPI configuration object
* @param options The request options from the service
* @param axiosClient The axios client instance to use
* @returns CancelablePromise<T>
* @throws ApiError
*/
export const request = <T>(
config: OpenAPIConfig,
options: ApiRequestOptions,
axiosClient: AxiosInstance = axios,
): CancelablePromise<T> => {
return new CancelablePromise(async (resolve, reject, onCancel) => {
try {
const url = getUrl(config, options);
const formData = getFormData(options);
const body = getRequestBody(options);
const headers = await getHeaders(config, options, formData);
if (!onCancel.isCancelled) {
const response = await sendRequest<T>(
config,
options,
url,
body,
formData,
headers,
onCancel,
axiosClient,
);
const responseBody = getResponseBody(response);
const responseHeader = getResponseHeader(
response,
options.responseHeader,
);
const result: ApiResult = {
url,
ok: isSuccess(response.status),
status: response.status,
statusText: response.statusText,
body: responseHeader ?? responseBody,
};
catchErrorCodes(options, result);
resolve(result.body);
}
} catch (error) {
reject(error);
}
});
};

View File

@ -0,0 +1,174 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export { ApiError } from "./core/ApiError";
export { CancelablePromise, CancelError } from "./core/CancelablePromise";
export { OpenAPI } from "./core/OpenAPI";
export type { OpenAPIConfig } from "./core/OpenAPI";
export type { _void } from "./models/_void";
export type { addressDto } from "./models/addressDto";
export { addressType } from "./models/addressType";
export { aspectType } from "./models/aspectType";
export type { assetCollectionDto } from "./models/assetCollectionDto";
export type { authDocumentDto } from "./models/authDocumentDto";
export type { authenticationResultDto } from "./models/authenticationResultDto";
export type { authHeaderResponseDto } from "./models/authHeaderResponseDto";
export type { baseCreateSellerRevisionDto } from "./models/baseCreateSellerRevisionDto";
export type { baseOfferDto } from "./models/baseOfferDto";
export type { baseTriggerDto } from "./models/baseTriggerDto";
export type { basketDto } from "./models/basketDto";
export type { bundleDiscountOfferDto } from "./models/bundleDiscountOfferDto";
export type { bundleTriggerDto } from "./models/bundleTriggerDto";
export type { calculatedCheckoutDto } from "./models/calculatedCheckoutDto";
export type { calculatedProductDto } from "./models/calculatedProductDto";
export type { categoryCompositeDto } from "./models/categoryCompositeDto";
export type { categoryDto } from "./models/categoryDto";
export type { categoryQueryRequestDto } from "./models/categoryQueryRequestDto";
export type { categoryRevisionDto } from "./models/categoryRevisionDto";
export type { categoryRevisionQueryRequestDto } from "./models/categoryRevisionQueryRequestDto";
export type { categoryStopListDto } from "./models/categoryStopListDto";
export type { categoryViewDto } from "./models/categoryViewDto";
export type { checkoutOptionsDto } from "./models/checkoutOptionsDto";
export { commonOrderFailureResult } from "./models/commonOrderFailureResult";
export { commonOrderStatus } from "./models/commonOrderStatus";
export type { couponTriggerDto } from "./models/couponTriggerDto";
export type { courierInfoDto } from "./models/courierInfoDto";
export type { createCategoryDto } from "./models/createCategoryDto";
export type { createCategoryRevisionDto } from "./models/createCategoryRevisionDto";
export type { createOptionsRevisionDto } from "./models/createOptionsRevisionDto";
export type { createProductDto } from "./models/createProductDto";
export type { createProductRevisionDto } from "./models/createProductRevisionDto";
export type { createPromotionDto } from "./models/createPromotionDto";
export type { createRestaurantDto } from "./models/createRestaurantDto";
export type { createRestaurantRevisionDto } from "./models/createRestaurantRevisionDto";
export type { createSellerDto } from "./models/createSellerDto";
export type { createSellerRevisionDto } from "./models/createSellerRevisionDto";
export type { createTextRevisionDto } from "./models/createTextRevisionDto";
export type { customerGroupTriggerDto } from "./models/customerGroupTriggerDto";
export type { customerHistoryTriggerDto } from "./models/customerHistoryTriggerDto";
export type { customerLoyaltyTriggerDto } from "./models/customerLoyaltyTriggerDto";
export type { deliveryAddressDto } from "./models/deliveryAddressDto";
export { deliveryFailureResult } from "./models/deliveryFailureResult";
export type { deliveryLocationDto } from "./models/deliveryLocationDto";
export { deliveryOrderStatus } from "./models/deliveryOrderStatus";
export type { deliveryStateDto } from "./models/deliveryStateDto";
export type { digitalEngagementTriggerDto } from "./models/digitalEngagementTriggerDto";
export type { discountProductOfferDto } from "./models/discountProductOfferDto";
export type { discountTotalOfferDto } from "./models/discountTotalOfferDto";
export { discountType } from "./models/discountType";
export { dispatchMethodType } from "./models/dispatchMethodType";
export type { freeDeliveryOfferDto } from "./models/freeDeliveryOfferDto";
export type { freeItemOfferDto } from "./models/freeItemOfferDto";
export type { gpsLocationDto } from "./models/gpsLocationDto";
export type { holidayTriggerDto } from "./models/holidayTriggerDto";
export type { humanVerificationRequestDto } from "./models/humanVerificationRequestDto";
export type { humanVerificationStatusDto } from "./models/humanVerificationStatusDto";
export type { imageReferenceDto } from "./models/imageReferenceDto";
export type { iTriggerDto } from "./models/iTriggerDto";
export type { localizedTextDto } from "./models/localizedTextDto";
export type { managerOverrideTriggerDto } from "./models/managerOverrideTriggerDto";
export type { oidcConnectResponseDto } from "./models/oidcConnectResponseDto";
export type { optionDto } from "./models/optionDto";
export type { optionsQueryDto } from "./models/optionsQueryDto";
export type { optionsRevisionDto } from "./models/optionsRevisionDto";
export type { optionsViewDto } from "./models/optionsViewDto";
export type { orderCreateDto } from "./models/orderCreateDto";
export type { orderDto } from "./models/orderDto";
export type { orderFailureRequestDto } from "./models/orderFailureRequestDto";
export type { orderNextStatusDto } from "./models/orderNextStatusDto";
export type { orderQueryRequestDto } from "./models/orderQueryRequestDto";
export type { orderStateChangeRequestDto } from "./models/orderStateChangeRequestDto";
export type { orderStateDto } from "./models/orderStateDto";
export { orderTriggerDto } from "./models/orderTriggerDto";
export type { orderViewDto } from "./models/orderViewDto";
export { paymentType } from "./models/paymentType";
export { phoneVerificationState } from "./models/phoneVerificationState";
export type { preparationStateDto } from "./models/preparationStateDto";
export type { priceEstimationDto } from "./models/priceEstimationDto";
export type { priceEstimationRequestDto } from "./models/priceEstimationRequestDto";
export type { problemDetails } from "./models/problemDetails";
export type { productCompositeDto } from "./models/productCompositeDto";
export type { productDto } from "./models/productDto";
export type { productQueryRequestDto } from "./models/productQueryRequestDto";
export type { productRevisionDto } from "./models/productRevisionDto";
export type { productRevisionQueryRequestDto } from "./models/productRevisionQueryRequestDto";
export type { productStopListDto } from "./models/productStopListDto";
export type { productUnavailableDto } from "./models/productUnavailableDto";
export { productUnavailableReason } from "./models/productUnavailableReason";
export type { promotionQueryRequestDto } from "./models/promotionQueryRequestDto";
export type { promotionViewDto } from "./models/promotionViewDto";
export type { purchaseTriggerDto } from "./models/purchaseTriggerDto";
export type { QueryResultDto_CategoryDto_ } from "./models/QueryResultDto_CategoryDto_";
export type { QueryResultDto_CategoryRevisionDto_ } from "./models/QueryResultDto_CategoryRevisionDto_";
export type { QueryResultDto_OptionsRevisionDto_ } from "./models/QueryResultDto_OptionsRevisionDto_";
export type { QueryResultDto_OrderViewDto_ } from "./models/QueryResultDto_OrderViewDto_";
export type { QueryResultDto_ProductRevisionDto_ } from "./models/QueryResultDto_ProductRevisionDto_";
export type { QueryResultDto_PromotionViewDto_ } from "./models/QueryResultDto_PromotionViewDto_";
export type { QueryResultDto_SellerDto_ } from "./models/QueryResultDto_SellerDto_";
export type { QueryResultDto_SellerRevisionDto_ } from "./models/QueryResultDto_SellerRevisionDto_";
export type { QueryResultDto_TextRevisionDto_ } from "./models/QueryResultDto_TextRevisionDto_";
export type { QueryResultDto_UserDto_ } from "./models/QueryResultDto_UserDto_";
export type { restaurantDto } from "./models/restaurantDto";
export type { restaurantRevisionDto } from "./models/restaurantRevisionDto";
export type { rolePermissionsDto } from "./models/rolePermissionsDto";
export type { scheduleDto } from "./models/scheduleDto";
export type { scheduleExceptionDto } from "./models/scheduleExceptionDto";
export type { selectedOptionDto } from "./models/selectedOptionDto";
export type { selectedProductDto } from "./models/selectedProductDto";
export type { sellerCompositeDto } from "./models/sellerCompositeDto";
export type { sellerDto } from "./models/sellerDto";
export type { sellerOperationalStateDto } from "./models/sellerOperationalStateDto";
export type { sellerOperationalStateTriggerDto } from "./models/sellerOperationalStateTriggerDto";
export type { sellerPublicAggregateFullDto } from "./models/sellerPublicAggregateFullDto";
export type { sellerQueryRequestDto } from "./models/sellerQueryRequestDto";
export type { sellerRevisionDto } from "./models/sellerRevisionDto";
export type { sellerRevisionQueryRequestDto } from "./models/sellerRevisionQueryRequestDto";
export type { sellerViewDto } from "./models/sellerViewDto";
export type { sessionIpResponseDto } from "./models/sessionIpResponseDto";
export { sessionStatus } from "./models/sessionStatus";
export type { sessionStatusDto } from "./models/sessionStatusDto";
export type { signedAuthDocumentDto } from "./models/signedAuthDocumentDto";
export type { simpleContactDto } from "./models/simpleContactDto";
export type { startSessionResponseDto } from "./models/startSessionResponseDto";
export type { stopListTriggerDto } from "./models/stopListTriggerDto";
export type { systemSecurityDto } from "./models/systemSecurityDto";
export type { tag } from "./models/tag";
export type { textQueryRequestDto } from "./models/textQueryRequestDto";
export type { textRevisionDto } from "./models/textRevisionDto";
export type { timeRangeDto } from "./models/timeRangeDto";
export type { timeTriggerDto } from "./models/timeTriggerDto";
export { triggerEffect } from "./models/triggerEffect";
export type { updatePermissionsRequest } from "./models/updatePermissionsRequest";
export type { userDto } from "./models/userDto";
export type { userQueryRequest } from "./models/userQueryRequest";
export type { verifiedValueDto } from "./models/verifiedValueDto";
export { versionCheck } from "./models/versionCheck";
export { AiService } from "./services/AiService";
export { AuthenticationService } from "./services/AuthenticationService";
export { CategoriesCanonicalService } from "./services/CategoriesCanonicalService";
export { CategoriesRevisionsService } from "./services/CategoriesRevisionsService";
export { CategoriesViewsService } from "./services/CategoriesViewsService";
export { DeliveriesService } from "./services/DeliveriesService";
export { ImagesService } from "./services/ImagesService";
export { OidcService } from "./services/OidcService";
export { OrdersService } from "./services/OrdersService";
export { OrdersBasketService } from "./services/OrdersBasketService";
export { ProductOptionsRevisionsService } from "./services/ProductOptionsRevisionsService";
export { ProductsCanonicalService } from "./services/ProductsCanonicalService";
export { ProductsRevisionsService } from "./services/ProductsRevisionsService";
export { ProductsViewsService } from "./services/ProductsViewsService";
export { PromotionsService } from "./services/PromotionsService";
export { SecurityService } from "./services/SecurityService";
export { SellersCanonicalService } from "./services/SellersCanonicalService";
export { SellersRevisionsService } from "./services/SellersRevisionsService";
export { SellersViewsService } from "./services/SellersViewsService";
export { ServerSideEventsService } from "./services/ServerSideEventsService";
export { SystemService } from "./services/SystemService";
export { TestsService } from "./services/TestsService";
export { TranslatableTextRevisionsService } from "./services/TranslatableTextRevisionsService";
export { UserService } from "./services/UserService";
export { VerificationsService } from "./services/VerificationsService";
export { WebService } from "./services/WebService";

View File

@ -0,0 +1,13 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { categoryDto } from "./categoryDto";
/**
* The UTC server time is included in the response to allow the client to know (by the server's clock) when the query was performed.
* It is important to populate this field with the server's time at the beginning of the query execution.
*/
export type QueryResultDto_CategoryDto_ = {
serverTimeUtc?: string;
results?: Array<categoryDto>;
};

View File

@ -0,0 +1,13 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { categoryRevisionDto } from "./categoryRevisionDto";
/**
* The UTC server time is included in the response to allow the client to know (by the server's clock) when the query was performed.
* It is important to populate this field with the server's time at the beginning of the query execution.
*/
export type QueryResultDto_CategoryRevisionDto_ = {
serverTimeUtc?: string;
results?: Array<categoryRevisionDto>;
};

View File

@ -0,0 +1,13 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { optionsRevisionDto } from "./optionsRevisionDto";
/**
* The UTC server time is included in the response to allow the client to know (by the server's clock) when the query was performed.
* It is important to populate this field with the server's time at the beginning of the query execution.
*/
export type QueryResultDto_OptionsRevisionDto_ = {
serverTimeUtc?: string;
results?: Array<optionsRevisionDto>;
};

View File

@ -0,0 +1,13 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { orderViewDto } from "./orderViewDto";
/**
* The UTC server time is included in the response to allow the client to know (by the server's clock) when the query was performed.
* It is important to populate this field with the server's time at the beginning of the query execution.
*/
export type QueryResultDto_OrderViewDto_ = {
serverTimeUtc?: string;
results?: Array<orderViewDto>;
};

View File

@ -0,0 +1,13 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { productRevisionDto } from "./productRevisionDto";
/**
* The UTC server time is included in the response to allow the client to know (by the server's clock) when the query was performed.
* It is important to populate this field with the server's time at the beginning of the query execution.
*/
export type QueryResultDto_ProductRevisionDto_ = {
serverTimeUtc?: string;
results?: Array<productRevisionDto>;
};

View File

@ -0,0 +1,13 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { promotionViewDto } from "./promotionViewDto";
/**
* The UTC server time is included in the response to allow the client to know (by the server's clock) when the query was performed.
* It is important to populate this field with the server's time at the beginning of the query execution.
*/
export type QueryResultDto_PromotionViewDto_ = {
serverTimeUtc?: string;
results?: Array<promotionViewDto>;
};

View File

@ -0,0 +1,13 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { sellerDto } from "./sellerDto";
/**
* The UTC server time is included in the response to allow the client to know (by the server's clock) when the query was performed.
* It is important to populate this field with the server's time at the beginning of the query execution.
*/
export type QueryResultDto_SellerDto_ = {
serverTimeUtc?: string;
results?: Array<sellerDto>;
};

View File

@ -0,0 +1,13 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { sellerRevisionDto } from "./sellerRevisionDto";
/**
* The UTC server time is included in the response to allow the client to know (by the server's clock) when the query was performed.
* It is important to populate this field with the server's time at the beginning of the query execution.
*/
export type QueryResultDto_SellerRevisionDto_ = {
serverTimeUtc?: string;
results?: Array<sellerRevisionDto>;
};

View File

@ -0,0 +1,13 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { textRevisionDto } from "./textRevisionDto";
/**
* The UTC server time is included in the response to allow the client to know (by the server's clock) when the query was performed.
* It is important to populate this field with the server's time at the beginning of the query execution.
*/
export type QueryResultDto_TextRevisionDto_ = {
serverTimeUtc?: string;
results?: Array<textRevisionDto>;
};

View File

@ -0,0 +1,13 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { userDto } from "./userDto";
/**
* The UTC server time is included in the response to allow the client to know (by the server's clock) when the query was performed.
* It is important to populate this field with the server's time at the beginning of the query execution.
*/
export type QueryResultDto_UserDto_ = {
serverTimeUtc?: string;
results?: Array<userDto>;
};

View File

@ -0,0 +1,5 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type _void = Record<string, any>;

View File

@ -0,0 +1,44 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { addressType } from "./addressType";
export type addressDto = {
/**
* Primary address line; required and defaults to an empty string.
*/
line1?: string | null;
/**
* Optional second address line for additional info.
*/
line2?: string | null;
/**
* Optional third address line.
*/
line3?: string | null;
/**
* Optional floor information (e.g., "3", "roof", "ground floor").
*/
floor?: string | null;
/**
* Optional flat or apartment number.
*/
flat?: string | null;
/**
* Optional building identifier (name or number).
*/
building?: string | null;
/**
* Optional comments for extra address details.
*/
comment?: string | null;
/**
* Optional postal code.
*/
postCode?: string | null;
/**
* Optional region (e.g., state or province).
*/
region?: string | null;
type?: addressType;
};

View File

@ -0,0 +1,36 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* **Enum Values:**
*
* - 0 - **None**
* - 1 - **House**
* - 2 - **Apartment**
* - 3 - **Office**
* - 4 - **Other**
*
*/
export enum addressType {
/**
* (value: 0)
*/
None = 0,
/**
* (value: 1)
*/
House = 1,
/**
* (value: 2)
*/
Apartment = 2,
/**
* (value: 3)
*/
Office = 3,
/**
* (value: 4)
*/
Other = 4,
}

View File

@ -0,0 +1,31 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* **Enum Values:**
*
* - 0 - **None**
* - 10 - **Landscape**
* - 20 - **Square**
* - 30 - **Portrait**
*
*/
export enum aspectType {
/**
* (value: 0)
*/
None = 0,
/**
* (value: 10)
*/
Landscape = 10,
/**
* (value: 20)
*/
Square = 20,
/**
* (value: 30)
*/
Portrait = 30,
}

View File

@ -0,0 +1,18 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* This Dto represents a reference to a list of stored media assets, such as an image or video.
* The assets are referenced by hash digests, which are unique identifiers for the asset.
*/
export type assetCollectionDto = {
/**
* This is the kind of media being referenced.
*/
kind?: string | null;
/**
* A list of base58-encoded SHA-256 digests for the media assets.
*/
digests?: Array<string> | null;
};

View File

@ -0,0 +1,30 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* Represents the authentication document that the mobile app creates.
* It is signed to prove the identity and includes session and timing details.
*/
export type authDocumentDto = {
/**
* Gets or sets the unique session identifier.
*/
sessionId: string | null;
/**
* Gets or sets the client IP address fetched from the hub.
*/
clientIp: string | null;
/**
* Gets or sets the timestamp when the auth document is issued.
*/
issuedAt?: string;
/**
* Gets or sets the timestamp when the auth document expires.
*/
expiresAt?: string;
/**
* Gets or sets the mobile app's Ed25519 public key.
*/
publicKey: string;
};

View File

@ -0,0 +1,25 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* Response DTO for retrieving the authentication header for a session.
*/
export type authHeaderResponseDto = {
/**
* Gets or sets the unique session identifier.
*/
sessionId: string | null;
/**
* Gets or sets the X-Signed-Auth header value.
*/
headerValue: string | null;
/**
* Gets or sets the public key used for authentication.
*/
publicKey: string | null;
/**
* The UTC expiry date of the signed authorization header.
*/
utcExpires: string;
};

View File

@ -0,0 +1,13 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* DTO for the authentication result.
*/
export type authenticationResultDto = {
/**
* Gets or sets the session ID that was authenticated.
*/
sessionId: string | null;
};

View File

@ -0,0 +1,37 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { addressDto } from "./addressDto";
import type { createRestaurantRevisionDto } from "./createRestaurantRevisionDto";
import type { createSellerRevisionDto } from "./createSellerRevisionDto";
import type { gpsLocationDto } from "./gpsLocationDto";
import type { imageReferenceDto } from "./imageReferenceDto";
import type { scheduleDto } from "./scheduleDto";
import type { simpleContactDto } from "./simpleContactDto";
/**
* Base class for seller data transfer objects, encapsulating common seller properties.
* Restaurants, farms, shops and other merchants inherit this class.
*/
export type baseCreateSellerRevisionDto =
| createRestaurantRevisionDto
| createSellerRevisionDto
| {
/**
* Version number of the document.
*/
documentVersion?: number;
schedule?: scheduleDto;
address?: addressDto;
contactInfo?: simpleContactDto;
location?: gpsLocationDto;
/**
* A key value collection for storing additional information
*/
additional?: Record<string, any> | null;
/**
* A list of image references.
*/
images?: Array<imageReferenceDto> | null;
type?: string;
};

View File

@ -0,0 +1,18 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { bundleDiscountOfferDto } from "./bundleDiscountOfferDto";
import type { discountProductOfferDto } from "./discountProductOfferDto";
import type { discountTotalOfferDto } from "./discountTotalOfferDto";
import type { freeDeliveryOfferDto } from "./freeDeliveryOfferDto";
import type { freeItemOfferDto } from "./freeItemOfferDto";
export type baseOfferDto =
| discountTotalOfferDto
| discountProductOfferDto
| freeDeliveryOfferDto
| freeItemOfferDto
| bundleDiscountOfferDto
| {
readonly type?: string | null;
};

View File

@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { triggerEffect } from "./triggerEffect";
export type baseTriggerDto = {
readonly type?: string | null;
effect?: triggerEffect;
};

View File

@ -0,0 +1,44 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { dispatchMethodType } from "./dispatchMethodType";
import type { gpsLocationDto } from "./gpsLocationDto";
import type { selectedProductDto } from "./selectedProductDto";
/**
* Represents a customer's basket containing selected products for checkout processing.
*/
export type basketDto = {
/**
* Gets the unique identifier for this basket.
*/
id?: string;
/**
* Gets the unique identifier of the seller.
*/
sellerId: string;
/**
* When the order is scheduled for delivery or pickup, this property indicates the scheduled time.
*/
scheduledFor?: string;
/**
* Gets the collection of selected products in this basket.
*/
items?: Array<selectedProductDto> | null;
gpsLocation: gpsLocationDto;
/**
* Gets or sets whether community change is enabled for this basket.
*/
isCommunityChangeEnabled?: boolean;
dispatchType?: dispatchMethodType;
/**
* Optional: The customer has selected a delivery price that was on offer.
*/
selectedDeliveryPrice?: {
/**
* Currency code ISO 4217
*/
currency?: string;
value?: number;
} | null;
};

View File

@ -0,0 +1,11 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { discountType } from "./discountType";
export type bundleDiscountOfferDto = {
readonly type?: "bundle_discount" | null;
productIds: Array<string> | null;
discountType: discountType;
value: number;
};

View File

@ -0,0 +1,10 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { triggerEffect } from "./triggerEffect";
export type bundleTriggerDto = {
effect?: triggerEffect;
productIds?: Array<string> | null;
readonly type?: "bundle" | null;
};

View File

@ -0,0 +1,122 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { calculatedProductDto } from "./calculatedProductDto";
import type { productUnavailableDto } from "./productUnavailableDto";
/**
* Represents a calculated checkout with comprehensive pricing information for all items, including taxes, discounts, and delivery fees.
*/
export type calculatedCheckoutDto = {
/**
* Must be set if the basket is to be considered valid.
*/
isValid?: boolean;
/**
* Gets or sets the collection of calculated products in this checkout.
*/
items?: Array<calculatedProductDto> | null;
/**
* Gets or sets the subtotal of all items before applying discounts.
*/
itemSubTotal?: {
/**
* Currency code ISO 4217
*/
currency?: string;
value?: number;
} | null;
/**
* Gets or sets the subtotal of all items with any discounts applied.
*/
itemDiscountedSubTotal?: {
/**
* Currency code ISO 4217
*/
currency?: string;
value?: number;
} | null;
/**
* Gets or sets the tax amount applied to this checkout, if any.
*/
tax?: {
/**
* Currency code ISO 4217
*/
currency?: string;
value?: number;
} | null;
/**
* Gets or sets the community contribution amount, if any.
*/
communityChange?: {
/**
* Currency code ISO 4217
*/
currency?: string;
value?: number;
} | null;
/**
* The user selected to enable community change.
*/
communityChangeIncluded?: boolean;
/**
* Returns the value of any discount applied at the order level.
*/
discount?: {
/**
* Currency code ISO 4217
*/
currency?: string;
value?: number;
} | null;
/**
* Returns the delivery fee, if any.
*/
delivery?: {
/**
* Currency code ISO 4217
*/
currency?: string;
value?: number;
} | null;
/**
* Gets or sets additional charges or fees associated with this checkout.
*/
extras?: Record<
string,
{
/**
* Currency code ISO 4217
*/
currency?: string;
value?: number;
}
> | null;
/**
* Gets or sets the final total price of this checkout after all calculations.
*/
final?: {
/**
* Currency code ISO 4217
*/
currency?: string;
value?: number;
} | null;
/**
* Gets the collection of all active promotion IDs applied to this checkout.
*/
readonly activePromotionIds?: Array<string> | null;
/**
* Gets or sets the list of unavailable products related to this checkout.
*/
unavailableProducts?: Array<productUnavailableDto> | null;
/**
* When doing cash payments, this is the first hint for the amount of cash the customer has prepared for the order.
*/
cashChangeHint1?: number | null;
/**
* When doing cash payments, this is the second hint for the amount of cash the customer has prepared for the order.
*/
cashChangeHint2?: number | null;
};

View File

@ -0,0 +1,64 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* Represents a calculated product with pricing information, including subtotal, discounts, and final price.
*/
export type calculatedProductDto = {
/**
* Represents the unique 'line' identifier of the item in the checkout.
*/
id: string;
/**
* Gets the unique identifier of the product.
*/
productId: string;
/**
* This value will be null if the Final amount is not a discount.
*/
subTotal: {
/**
* Currency code ISO 4217
*/
currency?: string;
value?: number;
} | null;
/**
* This value will be null if the Final amount is not a discount.
*/
discountedTotal?: {
/**
* Currency code ISO 4217
*/
currency?: string;
value?: number;
} | null;
/**
* Gets or sets additional charges or fees associated with this product.
*/
extras?: Record<
string,
{
/**
* Currency code ISO 4217
*/
currency?: string;
value?: number;
}
> | null;
/**
* Gets or sets the final price of this product after all calculations.
*/
final?: {
/**
* Currency code ISO 4217
*/
currency?: string;
value?: number;
} | null;
/**
* Gets or sets the collection of active promotion IDs applied to this product.
*/
activePromotionIds?: Array<string> | null;
};

View File

@ -0,0 +1,22 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { imageReferenceDto } from "./imageReferenceDto";
import type { localizedTextDto } from "./localizedTextDto";
import type { scheduleDto } from "./scheduleDto";
import type { tag } from "./tag";
export type categoryCompositeDto = {
tags?: Array<tag> | null;
additional?: Record<string, any> | null;
images?: Array<imageReferenceDto> | null;
schedule?: scheduleDto;
sortOrder?: number;
id?: string;
revisionId?: string;
promotionIds?: Array<string> | null;
title?: localizedTextDto;
description?: localizedTextDto;
isAvailable?: boolean;
isStopListed?: boolean;
};

View File

@ -0,0 +1,13 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { iTriggerDto } from "./iTriggerDto";
export type categoryDto = {
promotionIds?: Array<string> | null;
sellerId?: string;
availabilityTriggers?: Array<iTriggerDto> | null;
id?: string;
updatedByUserId?: string;
utcUpdated?: string;
};

View File

@ -0,0 +1,10 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type categoryQueryRequestDto = {
categoryIds?: Array<string> | null;
sellerId?: string | null;
offset?: number;
limit?: number;
};

View File

@ -0,0 +1,24 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { imageReferenceDto } from "./imageReferenceDto";
import type { localizedTextDto } from "./localizedTextDto";
import type { scheduleDto } from "./scheduleDto";
import type { tag } from "./tag";
export type categoryRevisionDto = {
tags?: Array<tag> | null;
additional?: Record<string, any> | null;
images?: Array<imageReferenceDto> | null;
schedule?: scheduleDto;
sortOrder?: number;
revisionId?: string;
categoryId?: string;
creatorId?: string;
utcCreated?: string;
title?: localizedTextDto;
description?: localizedTextDto;
isPublished?: boolean;
utcPublished?: string | null;
publishedByUserId?: string | null;
};

View File

@ -0,0 +1,14 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type categoryRevisionQueryRequestDto = {
categoryId?: string | null;
offset?: number;
limit?: number;
utcCreatedFrom?: string | null;
utcCreatedTo?: string | null;
isPublished?: boolean | null;
publishedByUserId?: string | null;
utcCreated?: string | null;
};

View File

@ -0,0 +1,7 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type categoryStopListDto = {
categoryIds: Array<string>;
};

View File

@ -0,0 +1,19 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { imageReferenceDto } from "./imageReferenceDto";
import type { localizedTextDto } from "./localizedTextDto";
import type { scheduleDto } from "./scheduleDto";
import type { tag } from "./tag";
export type categoryViewDto = {
tags?: Array<tag> | null;
additional?: Record<string, any> | null;
images?: Array<imageReferenceDto> | null;
schedule?: scheduleDto;
sortOrder?: number;
revisionId?: string;
categoryId?: string;
title?: localizedTextDto;
description?: localizedTextDto;
};

View File

@ -0,0 +1,27 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { deliveryLocationDto } from "./deliveryLocationDto";
import type { dispatchMethodType } from "./dispatchMethodType";
import type { simpleContactDto } from "./simpleContactDto";
/**
* Data transfer object for restaurant checkout options
*/
export type checkoutOptionsDto = {
dispatchType?: dispatchMethodType;
/**
* Gets or sets the scheduled date and time for the order.
*/
scheduledFor?: string | null;
contact?: simpleContactDto;
/**
* Gets or sets the cash amount prepared for the order.
*/
cashAmount?: number | null;
deliveryLocation?: deliveryLocationDto;
/**
* Gets or sets whether community change is enabled for this basket.
*/
isCommunityChangeEnabled?: boolean;
};

Some files were not shown because too many files have changed in this diff Show More