diff --git a/docker-compose.yml b/docker-compose.yml index d68f70c..e0e2d75 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,5 +29,25 @@ services: db: condition: service_healthy + frontend: + build: + context: ./frontend + args: + HTTP_PROXY: ${HTTP_PROXY:-} + HTTPS_PROXY: ${HTTPS_PROXY:-} + # Browser-side: NEXT_PUBLIC_API_URL is what the user's browser hits — must be host-reachable. + # Server-side: INTERNAL_API_URL is what the Next.js server hits when SSR fetches data — uses the compose network. + environment: + NEXT_PUBLIC_API_URL: http://localhost:8000 + INTERNAL_API_URL: http://web:8000 + volumes: + - ./frontend:/app + - /app/node_modules + - /app/.next + ports: + - "3000:3000" + depends_on: + - web + volumes: postgres_data: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..eb5b285 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,8 @@ +node_modules +.next +.env.local +.git +*.log +README.md +AGENTS.md +CLAUDE.md diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 0000000..18bad35 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1,4 @@ +fetch-timeout=600000 +fetch-retries=5 +fetch-retry-mintimeout=20000 +fetch-retry-maxtimeout=120000 diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md new file mode 100644 index 0000000..8bd0e39 --- /dev/null +++ b/frontend/AGENTS.md @@ -0,0 +1,5 @@ + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/frontend/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..ebf340d --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,28 @@ +FROM node:22-alpine + +ENV NEXT_TELEMETRY_DISABLED=1 + +WORKDIR /app + +# Build-time proxy. Compose passes these via build args. +# Active only during `npm install` below; cleared again before runtime. +ARG HTTP_PROXY= +ARG HTTPS_PROXY= +ENV HTTP_PROXY=${HTTP_PROXY} \ + HTTPS_PROXY=${HTTPS_PROXY} \ + http_proxy=${HTTP_PROXY} \ + https_proxy=${HTTPS_PROXY} + +COPY .npmrc package.json package-lock.json ./ +# `npm install` (not `npm ci`) is forgiving when the lockfile was generated +# against a different registry (e.g. npmjs) than the one in .npmrc. +RUN npm install --no-audit --no-fund + +# Drop the proxy from the runtime image — only the install step needed it. +ENV HTTP_PROXY= HTTPS_PROXY= http_proxy= https_proxy= + +COPY . . + +EXPOSE 3000 + +CMD ["npm", "run", "dev", "--", "-H", "0.0.0.0"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/frontend/next.config.ts b/frontend/next.config.ts new file mode 100644 index 0000000..e9ffa30 --- /dev/null +++ b/frontend/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + /* config options here */ +}; + +export default nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..ac5728c --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,976 @@ +{ + "name": "frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.1.0", + "dependencies": { + "next": "16.2.4", + "react": "19.2.4", + "react-dom": "19.2.4" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "typescript": "^5" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@next/env": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz", + "integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz", + "integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz", + "integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz", + "integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz", + "integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz", + "integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz", + "integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz", + "integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz", + "integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", + "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz", + "integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==", + "license": "MIT", + "dependencies": { + "@next/env": "16.2.4", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.2.4", + "@next/swc-darwin-x64": "16.2.4", + "@next/swc-linux-arm64-gnu": "16.2.4", + "@next/swc-linux-arm64-musl": "16.2.4", + "@next/swc-linux-x64-gnu": "16.2.4", + "@next/swc-linux-x64-musl": "16.2.4", + "@next/swc-win32-arm64-msvc": "16.2.4", + "@next/swc-win32-x64-msvc": "16.2.4", + "sharp": "^0.34.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e99b4a6 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,21 @@ +{ + "name": "frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "next": "16.2.4", + "react": "19.2.4", + "react-dom": "19.2.4" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "typescript": "^5" + } +} diff --git a/frontend/public/file.svg b/frontend/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/frontend/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/globe.svg b/frontend/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/frontend/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/next.svg b/frontend/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/frontend/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/vercel.svg b/frontend/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/frontend/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/window.svg b/frontend/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/frontend/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/app/[lang]/landing.css b/frontend/src/app/[lang]/landing.css new file mode 100644 index 0000000..683f173 --- /dev/null +++ b/frontend/src/app/[lang]/landing.css @@ -0,0 +1,152 @@ +/* Landing page styles — extracted from design/Ilo Landing.html */ +:root { --max-w: 1200px; } + +.landing { + background: var(--surface); + color: var(--ink); + overflow-x: hidden; +} + +/* — top nav — */ +.nav { + position: sticky; top: 0; z-index: 50; + background: rgba(255,255,255,.85); + backdrop-filter: saturate(140%) blur(12px); + -webkit-backdrop-filter: saturate(140%) blur(12px); + border-bottom: 1px solid var(--line-soft); +} +.nav-inner { + max-width: var(--max-w); + margin: 0 auto; + display: flex; align-items: center; gap: 32px; + padding: 14px 24px; +} +.nav-brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 17px; text-decoration: none; color: inherit; } +.nav-mark { + width: 32px; height: 32px; border-radius: 9px; + background: linear-gradient(140deg, var(--accent), var(--accent-2)); + color: white; font-family: var(--font-en); font-weight: 700; font-size: 13px; + display: grid; place-items: center; + box-shadow: 0 3px 10px oklch(0.72 0.14 40 / .35); +} +.nav-links { display: flex; gap: 4px; } +.nav-link { + padding: 8px 12px; border-radius: 8px; + font-size: 13.5px; color: var(--ink-2); + cursor: pointer; text-decoration: none; +} +.nav-link:hover { background: var(--surface-2); color: var(--ink); } +.nav-cta { margin-inline-start: auto; display: flex; gap: 8px; } +.nav-cta .btn { text-decoration: none; } + +/* — hero — */ +.hero { + max-width: var(--max-w); + margin: 0 auto; + padding: 72px 24px 56px; + display: grid; + grid-template-columns: 1.15fr 1fr; + gap: 48px; + align-items: center; + position: relative; +} +.hero::before { + content: ""; position: absolute; + inset: -10% 0 -20% auto; width: 60%; + background: radial-gradient(60% 60% at 70% 40%, oklch(0.84 0.10 40 / .35), transparent 70%); + filter: blur(40px); + z-index: -1; +} +.eyebrow { + display: inline-flex; align-items: center; gap: 8px; + padding: 6px 12px; border-radius: 999px; + background: var(--accent-soft); color: var(--accent-ink); + font-size: 12.5px; font-weight: 500; + margin-bottom: 20px; +} +.hero h1 { + font-size: 56px; line-height: 1.1; letter-spacing: -0.025em; + font-weight: 700; margin: 0 0 20px; + text-wrap: balance; +} +.hero h1 em { + font-style: normal; + background: linear-gradient(120deg, var(--accent), var(--accent-2)); + -webkit-background-clip: text; background-clip: text; color: transparent; +} +.hero p { + font-size: 18px; line-height: 1.65; color: var(--ink-2); + max-width: 540px; margin: 0 0 28px; + text-wrap: pretty; +} +.hero-search { + max-width: 540px; + background: white; + border-radius: 16px; + padding: 12px; + display: flex; gap: 8px; align-items: center; + border: 1px solid var(--line); + box-shadow: 0 12px 32px oklch(0.6 0.05 40 / .12); + margin-top: 28px; +} +.hero-search input { + flex: 1; border: 0; outline: 0; background: transparent; + font: inherit; font-size: 15px; padding: 6px 10px; + color: var(--ink); +} +.hero-search .btn { padding: 10px 18px; } +.hero-trust { + display: flex; gap: 24px; margin-top: 32px; + color: var(--ink-3); font-size: 13px; +} +.hero-trust b { color: var(--ink); font-family: var(--font-num); font-weight: 700; } + +.hero-visual { + position: relative; + aspect-ratio: 4/4.4; + border-radius: 24px; + background: linear-gradient(150deg, oklch(0.86 0.10 40), oklch(0.82 0.08 80)); + overflow: hidden; + box-shadow: 0 24px 60px oklch(0.6 0.10 40 / .25); +} +.hero-cards { + position: absolute; inset: 0; + display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; + gap: 16px; padding: 24px; +} +.hv-card { + background: white; border-radius: 14px; + padding: 14px; display: flex; flex-direction: column; gap: 6px; + box-shadow: 0 6px 20px rgba(0,0,0,.08); +} +.hv-card .swatch { height: 60px; border-radius: 8px; margin-bottom: 4px; } +.hv-card .ttl { font-size: 13.5px; font-weight: 600; line-height: 1.4; } +.hv-card .meta { font-size: 11px; color: var(--ink-3); } +.hv-card .row { display: flex; align-items: center; gap: 6px; margin-top: auto; font-size: 11px; color: var(--ink-3); } + +/* — section base — */ +section.s { + max-width: var(--max-w); + margin: 0 auto; + padding: 64px 24px; +} +.s-h { + text-align: center; + margin-bottom: 40px; +} +.s-h .eyebrow { margin: 0 auto 14px; } +.s-h h2 { + font-size: 36px; letter-spacing: -0.02em; font-weight: 700; + margin: 0 0 10px; +} +.s-h p { + font-size: 16px; color: var(--ink-2); margin: 0; + max-width: 560px; margin: 0 auto; +} + +@media (max-width: 900px) { + .hero { grid-template-columns: 1fr; padding: 48px 20px; } + .hero h1 { font-size: 38px; } + .hero-visual { display: none; } + .nav-links { display: none; } +} diff --git a/frontend/src/app/[lang]/layout.tsx b/frontend/src/app/[lang]/layout.tsx new file mode 100644 index 0000000..213169a --- /dev/null +++ b/frontend/src/app/[lang]/layout.tsx @@ -0,0 +1,45 @@ +import type { Metadata } from "next"; +import { Inter, Vazirmatn } from "next/font/google"; +import { notFound } from "next/navigation"; +import { dir, isLocale, locales } from "@/lib/i18n"; +import "../globals.css"; + +const vazirmatn = Vazirmatn({ + subsets: ["arabic", "latin"], + display: "swap", + variable: "--font-vazirmatn", +}); + +const inter = Inter({ + subsets: ["latin"], + display: "swap", + variable: "--font-inter", +}); + +export const metadata: Metadata = { + title: "ایلو — یاد بگیر، رشد کن", + description: + "ایلو پلتفرم یادگیری آنلاین فارسی‌زبانه. صدها دوره از مدرس‌های متخصص.", +}; + +export function generateStaticParams() { + return locales.map((lang) => ({ lang })); +} + +export default async function RootLayout({ + children, + params, +}: LayoutProps<"/[lang]">) { + const { lang } = await params; + if (!isLocale(lang)) notFound(); + + return ( + + {children} + + ); +} diff --git a/frontend/src/app/[lang]/page.tsx b/frontend/src/app/[lang]/page.tsx new file mode 100644 index 0000000..10ae53c --- /dev/null +++ b/frontend/src/app/[lang]/page.tsx @@ -0,0 +1,115 @@ +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { getDictionary, isLocale } from "@/lib/i18n"; +import "./landing.css"; + +export default async function LandingPage({ params }: PageProps<"/[lang]">) { + const { lang } = await params; + if (!isLocale(lang)) notFound(); + const dict = await getDictionary(lang); + const t = dict.landing; + const nav = dict.nav; + + return ( +
+ + +
+
+ 🔥 {t.eyebrow} +

+ {t.headline_a} {t.headline_b} + {t.headline_c} +
+ {t.headline_d} +

+

{t.lede}

+
+ + +
+
+ + ۲۴۰+ {t.trust_courses} + + + ۸۰+ {t.trust_instructors} + + + ۱۲۰هزار {t.trust_students} + + + ۴.۸ ⭐ {t.trust_rating} + +
+
+ +
+
+
+
+
طراحی رابط کاربری
+
آرش پاکدل
+
+ ⭐ ۴.۹ + · + ۱۲۸۴ دانشجو +
+
+
+
+
پایتون از صفر
+
رامین صدر
+
+ ⭐ ۴.۹ + · + ۲۱۰۴ دانشجو +
+
+
+
+
سئو و بازاریابی
+
ندا فرهادی
+
+ ⭐ ۴.۸ + · + ۶۳۴ دانشجو +
+
+
+
+
عکاسی موبایل
+
سارا کاویانی
+
+ ⭐ ۴.۷ + · + ۳۱۲ دانشجو +
+
+
+
+
+
+ ); +} diff --git a/frontend/src/app/favicon.ico b/frontend/src/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/frontend/src/app/favicon.ico differ diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css new file mode 100644 index 0000000..03456d2 --- /dev/null +++ b/frontend/src/app/globals.css @@ -0,0 +1,550 @@ +/* ── Ilo design tokens ─────────────────────────────────────────── */ +:root { + /* type — --font-vazirmatn / --font-inter come from next/font in layout.tsx */ + --font-fa: var(--font-vazirmatn), ui-sans-serif, system-ui, sans-serif; + --font-en: var(--font-inter), ui-sans-serif, system-ui, sans-serif; + --font-num: var(--font-inter), var(--font-vazirmatn), ui-sans-serif, sans-serif; + + /* warm neutrals (light) */ + --bg: oklch(0.985 0.005 80); + --surface: oklch(1 0 0); + --surface-2: oklch(0.97 0.006 80); + --line: oklch(0.91 0.008 80); + --line-soft: oklch(0.94 0.006 80); + --ink: oklch(0.24 0.012 60); + --ink-2: oklch(0.40 0.010 60); + --ink-3: oklch(0.58 0.010 60); + --ink-4: oklch(0.74 0.008 60); + + /* accents (matched chroma 0.14) */ + --accent: oklch(0.72 0.14 40); /* coral-orange */ + --accent-2: oklch(0.66 0.14 40); + --accent-soft: oklch(0.94 0.04 40); + --accent-ink: oklch(0.42 0.10 40); + + --ok: oklch(0.70 0.14 155); + --ok-soft: oklch(0.94 0.04 155); + --warn: oklch(0.78 0.14 75); + --warn-soft: oklch(0.95 0.04 75); + --info: oklch(0.70 0.10 240); + --info-soft: oklch(0.94 0.03 240); + + /* radii */ + --r-xs: 6px; + --r-sm: 8px; + --r-md: 12px; + --r-lg: 16px; + --r-xl: 24px; + --r-pill: 999px; + + /* shadows */ + --sh-1: 0 1px 0 rgba(20, 14, 0, 0.02), 0 1px 2px rgba(20, 14, 0, 0.04); + --sh-2: 0 1px 2px rgba(20, 14, 0, 0.04), 0 8px 24px rgba(20, 14, 0, 0.06); + --sh-3: 0 12px 40px rgba(20, 14, 0, 0.10); + + /* density (regular) */ + --dens: 1; + --pad-card: 20px; + --pad-row: 14px; + --gap-md: 16px; + --gap-lg: 24px; + + /* layout */ + --sidebar-w: 248px; + --topbar-h: 60px; +} + +[data-theme="dark"] { + --bg: oklch(0.20 0.012 60); + --surface: oklch(0.25 0.012 60); + --surface-2: oklch(0.22 0.012 60); + --line: oklch(0.32 0.012 60); + --line-soft: oklch(0.28 0.012 60); + --ink: oklch(0.96 0.005 80); + --ink-2: oklch(0.82 0.008 70); + --ink-3: oklch(0.66 0.010 70); + --ink-4: oklch(0.50 0.010 70); + --accent-soft: oklch(0.32 0.06 40); + --accent-ink: oklch(0.84 0.10 40); + --ok-soft: oklch(0.30 0.05 155); + --warn-soft: oklch(0.32 0.06 75); + --info-soft: oklch(0.30 0.04 240); + --sh-1: 0 1px 0 rgba(0,0,0,.2), 0 1px 2px rgba(0,0,0,.25); + --sh-2: 0 1px 2px rgba(0,0,0,.2), 0 8px 24px rgba(0,0,0,.3); +} + +[data-density="compact"] { + --pad-card: 14px; + --pad-row: 10px; + --gap-md: 12px; + --gap-lg: 18px; + --sidebar-w: 220px; + --topbar-h: 52px; +} +[data-density="cozy"] { + --pad-card: 26px; + --pad-row: 18px; + --gap-md: 20px; + --gap-lg: 32px; + --sidebar-w: 268px; + --topbar-h: 68px; +} + +/* ── base ─────────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; } +html, body, #root { height: 100%; } +body { + margin: 0; + font-family: var(--font-fa); + font-size: 14px; + line-height: 1.55; + color: var(--ink); + background: var(--bg); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +[dir="rtl"] body { letter-spacing: 0; } +.en, [lang="en"] { font-family: var(--font-en); letter-spacing: -0.01em; } +.num { font-family: var(--font-num); font-variant-numeric: tabular-nums; } + +button { font-family: inherit; } +::selection { background: var(--accent-soft); color: var(--accent-ink); } + +/* ── shell ────────────────────────────────────────────────────── */ +.app { + display: grid; + grid-template-columns: var(--sidebar-w) 1fr; + height: 100vh; + background: var(--bg); +} + +/* sidebar */ +.sb { + display: flex; + flex-direction: column; + border-inline-end: 1px solid var(--line); + background: var(--surface-2); + padding: 16px 12px; + gap: 4px; + overflow-y: auto; +} +.sb-brand { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 10px 18px; +} +.sb-mark { + width: 30px; height: 30px; + border-radius: 9px; + background: linear-gradient(140deg, var(--accent), var(--accent-2)); + display: grid; place-items: center; + color: white; + font-weight: 700; + font-family: var(--font-en); + font-size: 14px; + letter-spacing: -0.02em; + box-shadow: var(--sh-1); +} +.sb-name { + font-weight: 700; + font-size: 16px; + letter-spacing: -0.01em; +} +.sb-sub { + font-size: 11px; + color: var(--ink-3); + margin-top: -2px; +} +.sb-section { + padding: 16px 10px 6px; + font-size: 10.5px; + letter-spacing: 0.06em; + color: var(--ink-3); + font-weight: 600; + text-transform: uppercase; +} +.sb-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: var(--r-sm); + color: var(--ink-2); + cursor: pointer; + font-size: 13.5px; + font-weight: 500; + user-select: none; + border: 1px solid transparent; + background: transparent; + width: 100%; + text-align: start; +} +.sb-item:hover { background: var(--line-soft); color: var(--ink); } +.sb-item[aria-current="page"] { + background: var(--surface); + color: var(--ink); + border-color: var(--line); + box-shadow: var(--sh-1); +} +.sb-item .ic { color: var(--ink-3); } +.sb-item[aria-current="page"] .ic { color: var(--accent); } +.sb-item .badge { + margin-inline-start: auto; + font-size: 10.5px; + background: var(--accent-soft); + color: var(--accent-ink); + padding: 1px 7px; + border-radius: var(--r-pill); + font-weight: 600; +} + +.sb-bottom { + margin-top: auto; + padding-top: 12px; + border-top: 1px solid var(--line-soft); +} +.sb-user { + display: flex; align-items: center; gap: 10px; + padding: 8px 10px; + border-radius: var(--r-sm); + cursor: pointer; +} +.sb-user:hover { background: var(--line-soft); } +.sb-avatar { + width: 32px; height: 32px; + border-radius: 50%; + background: linear-gradient(135deg, oklch(0.85 0.05 80), oklch(0.78 0.07 60)); + display: grid; place-items: center; + color: white; + font-weight: 600; + font-size: 12px; +} +.sb-user-meta { flex: 1; min-width: 0; } +.sb-user-name { font-size: 13px; font-weight: 600; } +.sb-user-role { font-size: 11px; color: var(--ink-3); } + +/* main */ +.main { + display: flex; + flex-direction: column; + min-width: 0; + overflow: hidden; +} +.topbar { + height: var(--topbar-h); + border-bottom: 1px solid var(--line); + display: flex; + align-items: center; + gap: 12px; + padding: 0 24px; + background: color-mix(in oklch, var(--surface) 75%, transparent); + backdrop-filter: blur(12px); + position: sticky; top: 0; z-index: 5; + flex-shrink: 0; +} +.crumb { + display: flex; align-items: center; gap: 6px; + font-size: 13px; + color: var(--ink-3); +} +.crumb b { color: var(--ink); font-weight: 600; } +.crumb .sep { color: var(--ink-4); } +.topbar-spacer { flex: 1; } + +.search { + display: flex; align-items: center; gap: 8px; + background: var(--surface-2); + border: 1px solid var(--line); + border-radius: var(--r-sm); + padding: 6px 12px; + width: 280px; + color: var(--ink-3); +} +.search input { + flex: 1; + border: 0; outline: 0; background: transparent; + font: inherit; color: var(--ink); +} +.search kbd { + font-family: var(--font-en); + font-size: 10px; + background: var(--surface); + border: 1px solid var(--line); + padding: 1px 6px; + border-radius: 4px; + color: var(--ink-3); +} + +.iconbtn { + width: 34px; height: 34px; + border: 1px solid var(--line); + background: var(--surface); + border-radius: var(--r-sm); + display: grid; place-items: center; + color: var(--ink-2); + cursor: pointer; + position: relative; +} +.iconbtn:hover { background: var(--surface-2); color: var(--ink); } +.iconbtn .dot { + position: absolute; top: 6px; inset-inline-end: 6px; + width: 7px; height: 7px; + background: var(--accent); + border-radius: 50%; + border: 2px solid var(--surface); +} + +.page { + flex: 1; + overflow-y: auto; + padding: var(--gap-lg); +} +.page-inner { + max-width: 1280px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: var(--gap-lg); +} + +.page-h { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} +.page-h h1 { + font-size: 26px; + letter-spacing: -0.015em; + margin: 0 0 4px; + font-weight: 700; +} +.page-h .lede { + color: var(--ink-3); + margin: 0; + font-size: 14px; +} + +/* ── primitives ───────────────────────────────────────────────── */ +.card { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--r-md); + box-shadow: var(--sh-1); +} +.card-pad { padding: var(--pad-card); } +.card-h { + padding: 16px var(--pad-card); + border-bottom: 1px solid var(--line-soft); + display: flex; align-items: center; justify-content: space-between; + gap: 12px; +} +.card-h h3 { + margin: 0; + font-size: 15px; + font-weight: 600; +} +.card-h .meta { + font-size: 12px; color: var(--ink-3); +} + +.btn { + appearance: none; + border: 1px solid var(--line); + background: var(--surface); + color: var(--ink); + padding: 8px 14px; + border-radius: var(--r-sm); + font-size: 13px; + font-weight: 500; + cursor: pointer; + display: inline-flex; align-items: center; gap: 6px; + transition: background .15s, transform .05s; +} +.btn:hover { background: var(--surface-2); } +.btn:active { transform: translateY(1px); } +.btn.primary { + background: var(--accent); + color: white; + border-color: transparent; + box-shadow: 0 1px 2px rgba(180, 80, 30, 0.25); +} +.btn.primary:hover { background: var(--accent-2); } +.btn.ghost { border-color: transparent; background: transparent; } +.btn.ghost:hover { background: var(--line-soft); } +.btn.sm { padding: 5px 10px; font-size: 12px; } + +.pill { + display: inline-flex; align-items: center; gap: 5px; + font-size: 11.5px; + padding: 2px 9px; + border-radius: var(--r-pill); + background: var(--surface-2); + color: var(--ink-2); + font-weight: 500; + border: 1px solid var(--line-soft); +} +.pill.ok { background: var(--ok-soft); color: oklch(0.40 0.10 155); border-color: transparent; } +.pill.warn { background: var(--warn-soft); color: oklch(0.46 0.10 75); border-color: transparent; } +.pill.info { background: var(--info-soft); color: oklch(0.42 0.08 240); border-color: transparent; } +.pill.accent { background: var(--accent-soft); color: var(--accent-ink); border-color: transparent; } + +.dot-i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; } + +.divider { height: 1px; background: var(--line-soft); } + +.muted { color: var(--ink-3); } +.tabular { font-variant-numeric: tabular-nums; } + +/* avatar */ +.av { + width: 32px; height: 32px; + border-radius: 50%; + display: grid; place-items: center; + color: white; + font-weight: 600; + font-size: 12px; + font-family: var(--font-en); + flex-shrink: 0; +} +.av-sm { width: 24px; height: 24px; font-size: 10px; } +.av-lg { width: 40px; height: 40px; font-size: 14px; } + +/* progress */ +.prog { + height: 6px; + background: var(--line-soft); + border-radius: var(--r-pill); + overflow: hidden; +} +.prog > i { + display: block; + height: 100%; + background: var(--accent); + border-radius: var(--r-pill); +} + +/* tabs */ +.tabs { + display: flex; + gap: 2px; + border-bottom: 1px solid var(--line); +} +.tab { + appearance: none; background: transparent; border: 0; + padding: 10px 14px; + font: inherit; font-size: 13px; font-weight: 500; + color: var(--ink-3); + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -1px; +} +.tab:hover { color: var(--ink); } +.tab[aria-selected="true"] { + color: var(--ink); + border-bottom-color: var(--accent); +} + +/* table */ +.tbl { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +.tbl th, .tbl td { + text-align: start; + padding: 12px 16px; + border-bottom: 1px solid var(--line-soft); +} +.tbl th { + font-weight: 500; + font-size: 11.5px; + color: var(--ink-3); + text-transform: uppercase; + letter-spacing: 0.04em; + background: var(--surface-2); +} +.tbl tr:last-child td { border-bottom: 0; } +.tbl tbody tr { cursor: pointer; } +.tbl tbody tr:hover { background: var(--surface-2); } + +/* checkbox */ +.chk { + appearance: none; + width: 16px; height: 16px; + border: 1.5px solid var(--ink-4); + border-radius: 4px; + display: inline-grid; place-items: center; + cursor: pointer; + background: var(--surface); + position: relative; +} +.chk:checked { + background: var(--accent); + border-color: var(--accent); +} +.chk:checked::after { + content: ""; + width: 9px; height: 5px; + border-left: 1.5px solid white; + border-bottom: 1.5px solid white; + transform: rotate(-45deg) translate(1px, -1px); +} + +/* input */ +.inp { + width: 100%; + padding: 9px 12px; + border: 1px solid var(--line); + background: var(--surface); + border-radius: var(--r-sm); + font: inherit; + color: var(--ink); + outline: none; +} +.inp:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } + +/* utility flex grids */ +.row { display: flex; gap: var(--gap-md); } +.col { display: flex; flex-direction: column; gap: var(--gap-md); } +.between { display: flex; align-items: center; justify-content: space-between; gap: 12px; } + +/* skeleton stripe placeholder */ +.placeholder { + background: + repeating-linear-gradient( + 135deg, + var(--surface-2), + var(--surface-2) 10px, + var(--line-soft) 10px, + var(--line-soft) 11px + ); + border: 1px dashed var(--line); + border-radius: var(--r-sm); + display: grid; place-items: center; + color: var(--ink-3); + font-family: var(--font-en); + font-size: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +/* ── icons ───────────────────────────────────────────────────── */ +.ic { width: 18px; height: 18px; flex-shrink: 0; } +.ic-sm { width: 14px; height: 14px; flex-shrink: 0; } +.ic-lg { width: 22px; height: 22px; flex-shrink: 0; } + +/* RTL flips for the chevrons */ +[dir="rtl"] .flipx { transform: scaleX(-1); } + +/* ── motion ──────────────────────────────────────────────────── */ +@keyframes fadeUp { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} +.page-inner > * { animation: fadeUp .22s ease both; } +.page-inner > *:nth-child(2) { animation-delay: 0.04s; } +.page-inner > *:nth-child(3) { animation-delay: 0.08s; } +.page-inner > *:nth-child(4) { animation-delay: 0.12s; } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..014a30c --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,52 @@ +// On the server (SSR / route handlers / RSC) prefer INTERNAL_API_URL — Docker compose service name. +// In the browser, NEXT_PUBLIC_API_URL is baked in at build time and points at the host. +const rawBaseUrl = + typeof window === "undefined" + ? process.env.INTERNAL_API_URL ?? process.env.NEXT_PUBLIC_API_URL + : process.env.NEXT_PUBLIC_API_URL; +const baseUrl = (rawBaseUrl ?? "http://localhost:8000").replace(/\/$/, ""); + +export class ApiError extends Error { + constructor(public status: number, message: string, public body?: unknown) { + super(message); + this.name = "ApiError"; + } +} + +type FetchInit = RequestInit & { token?: string; locale?: string }; + +async function request(path: string, init: FetchInit = {}): Promise { + const { token, locale, headers, ...rest } = init; + const res = await fetch(`${baseUrl}${path.startsWith("/") ? path : `/${path}`}`, { + ...rest, + headers: { + "Content-Type": "application/json", + ...(locale ? { "Accept-Language": locale } : {}), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...headers, + }, + }); + + if (!res.ok) { + let body: unknown; + try { + body = await res.json(); + } catch { + body = await res.text().catch(() => undefined); + } + throw new ApiError(res.status, `${res.status} ${res.statusText}`, body); + } + + if (res.status === 204) return undefined as T; + return res.json() as Promise; +} + +export const api = { + baseUrl, + get: (path: string, init?: FetchInit) => request(path, { ...init, method: "GET" }), + post: (path: string, body?: unknown, init?: FetchInit) => + request(path, { ...init, method: "POST", body: body ? JSON.stringify(body) : undefined }), + patch: (path: string, body?: unknown, init?: FetchInit) => + request(path, { ...init, method: "PATCH", body: body ? JSON.stringify(body) : undefined }), + delete: (path: string, init?: FetchInit) => request(path, { ...init, method: "DELETE" }), +}; diff --git a/frontend/src/lib/dictionaries/en.json b/frontend/src/lib/dictionaries/en.json new file mode 100644 index 0000000..11e692c --- /dev/null +++ b/frontend/src/lib/dictionaries/en.json @@ -0,0 +1,24 @@ +{ + "nav": { + "courses": "Courses", + "instructors": "Instructors", + "business": "For business", + "blog": "Blog", + "login": "Log in", + "signup": "Sign up free" + }, + "landing": { + "eyebrow": "+2,000 active students this week", + "headline_a": "Learn from", + "headline_b": "the best instructors", + "headline_c": ",", + "headline_d": "anywhere you are.", + "lede": "Ilo is a Persian-language online learning platform. Hundreds of courses from expert instructors — from design and coding to business and photography.", + "search_placeholder": "What do you want to learn?", + "search_button": "Search", + "trust_courses": "courses", + "trust_instructors": "expert instructors", + "trust_students": "students", + "trust_rating": "avg. satisfaction" + } +} diff --git a/frontend/src/lib/dictionaries/fa.json b/frontend/src/lib/dictionaries/fa.json new file mode 100644 index 0000000..6f70412 --- /dev/null +++ b/frontend/src/lib/dictionaries/fa.json @@ -0,0 +1,24 @@ +{ + "nav": { + "courses": "دوره‌ها", + "instructors": "مدرس‌ها", + "business": "برای کسب‌وکارها", + "blog": "وبلاگ", + "login": "ورود", + "signup": "ثبت‌نام رایگان" + }, + "landing": { + "eyebrow": "+۲٬۰۰۰ دانشجوی فعال این هفته", + "headline_a": "یاد بگیر از", + "headline_b": "بهترین مدرس‌ها", + "headline_c": "،", + "headline_d": "هر جا که هستی.", + "lede": "ایلو پلتفرم یادگیری آنلاین فارسی‌زبانه. صدها دوره از مدرس‌های متخصص — از طراحی و کدنویسی گرفته تا کسب‌وکار و عکاسی.", + "search_placeholder": "چی می‌خوای یاد بگیری؟", + "search_button": "جستجو", + "trust_courses": "دوره", + "trust_instructors": "مدرس متخصص", + "trust_students": "دانشجو", + "trust_rating": "میانگین رضایت" + } +} diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts new file mode 100644 index 0000000..10790ca --- /dev/null +++ b/frontend/src/lib/i18n.ts @@ -0,0 +1,20 @@ +import "server-only"; + +export const locales = ["fa", "en"] as const; +export type Locale = (typeof locales)[number]; +export const defaultLocale: Locale = "fa"; + +export const isLocale = (value: string): value is Locale => + (locales as readonly string[]).includes(value); + +export const dir = (locale: Locale) => (locale === "fa" ? "rtl" : "ltr"); + +const dictionaries = { + fa: () => import("./dictionaries/fa.json").then((m) => m.default), + en: () => import("./dictionaries/en.json").then((m) => m.default), +} satisfies Record Promise>; + +export type Dictionary = Awaited>; + +export const getDictionary = (locale: Locale): Promise => + dictionaries[locale]() as Promise; diff --git a/frontend/src/proxy.ts b/frontend/src/proxy.ts new file mode 100644 index 0000000..932a1f6 --- /dev/null +++ b/frontend/src/proxy.ts @@ -0,0 +1,30 @@ +import { NextResponse, type NextRequest } from "next/server"; + +const locales = ["fa", "en"] as const; +const defaultLocale = "fa"; + +function pickLocale(req: NextRequest): string { + const accept = req.headers.get("accept-language") ?? ""; + const preferred = accept + .split(",") + .map((p) => p.trim().split(";")[0]!.toLowerCase().split("-")[0]) + .find((tag) => (locales as readonly string[]).includes(tag)); + return preferred ?? defaultLocale; +} + +export function proxy(req: NextRequest) { + const { pathname } = req.nextUrl; + const hasLocale = locales.some( + (loc) => pathname === `/${loc}` || pathname.startsWith(`/${loc}/`), + ); + if (hasLocale) return; + + const locale = pickLocale(req); + const url = req.nextUrl.clone(); + url.pathname = `/${locale}${pathname === "/" ? "" : pathname}`; + return NextResponse.redirect(url); +} + +export const config = { + matcher: ["/((?!_next|api|favicon.ico|.*\\..*).*)"], +}; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..cf9c65d --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +}