diff --git a/.forgejo/workflows/deploy-frontend-beta.yaml b/.forgejo/workflows/deploy-frontend-beta.yaml deleted file mode 100644 index a596d4ba..00000000 --- a/.forgejo/workflows/deploy-frontend-beta.yaml +++ /dev/null @@ -1,39 +0,0 @@ -name: Deploy Frontend Beta (S3) - -on: - push: - branches: - - beta - -jobs: - deploy-frontend: - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: https://github.com/actions/checkout@v4 - - - name: Install Dependencies and Build - env: - VITE_API_BASE_URL: https://betaapicsa.tradhox.com/api - run: | - cd frontend - npm ci - npm run build - - - name: Deploy to AWS S3 & Invalidate CloudFront - env: - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_DEFAULT_REGION: ap-south-2 - S3_BUCKET: ${{ secrets.AWS_S3_BUCKET_BETA || 'betacsa' }} - CLOUDFRONT_DISTRIBUTION_ID: ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID_BETA || 'E8KO4T7M0MRBS' }} - run: | - apk add --no-cache aws-cli - if [ -z "$AWS_ACCESS_KEY_ID" ]; then - unset AWS_ACCESS_KEY_ID - unset AWS_SECRET_ACCESS_KEY - fi - aws s3 sync frontend/dist/ s3://${S3_BUCKET} --delete --region ap-south-2 - if [ -n "$CLOUDFRONT_DISTRIBUTION_ID" ]; then - aws cloudfront create-invalidation --distribution-id ${CLOUDFRONT_DISTRIBUTION_ID} --paths "/*" --region ap-south-2 - fi diff --git a/.forgejo/workflows/deploy-frontend-production.yaml b/.forgejo/workflows/deploy-frontend-production.yaml deleted file mode 100644 index d640d771..00000000 --- a/.forgejo/workflows/deploy-frontend-production.yaml +++ /dev/null @@ -1,39 +0,0 @@ -name: Deploy Frontend Production (S3) - -on: - push: - branches: - - main - -jobs: - deploy-frontend: - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: https://github.com/actions/checkout@v4 - - - name: Install Dependencies and Build - env: - VITE_API_BASE_URL: https://apicsa.tradhox.com/api - run: | - cd frontend - npm ci - npm run build - - - name: Deploy to AWS S3 & Invalidate CloudFront - env: - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_DEFAULT_REGION: ap-south-2 - S3_BUCKET: ${{ secrets.AWS_S3_BUCKET_PRODUCTION || 'csawebsite' }} - CLOUDFRONT_DISTRIBUTION_ID: ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID_PRODUCTION || 'E2G2C75ZGRMFVG' }} - run: | - apk add --no-cache aws-cli - if [ -z "$AWS_ACCESS_KEY_ID" ]; then - unset AWS_ACCESS_KEY_ID - unset AWS_SECRET_ACCESS_KEY - fi - aws s3 sync frontend/dist/ s3://${S3_BUCKET} --delete --region ap-south-2 - if [ -n "$CLOUDFRONT_DISTRIBUTION_ID" ]; then - aws cloudfront create-invalidation --distribution-id ${CLOUDFRONT_DISTRIBUTION_ID} --paths "/*" --region ap-south-2 - fi diff --git a/.gitignore b/.gitignore index c2680718..2f75e907 100644 --- a/.gitignore +++ b/.gitignore @@ -57,9 +57,11 @@ site/ # Node.js / Frontend frontend/node_modules/ -frontend/dist/ +frontend/.svelte-kit/ +frontend/build/ frontend/.env frontend/.env.local +frontend/package-lock.json # Logs logs/ diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 00000000..42f89fe6 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,30 @@ +# The compose frontend service builds with `context: ./frontend`, and Docker +# reads .dockerignore from the context root, so the repo-root .dockerignore has +# never applied to this build. Without this file the whole of node_modules and +# .svelte-kit ships to the daemon on every build. + +# Secrets. Load-bearing, not tidiness: the builder stage copies the source in, +# and vite.config.js calls loadEnv(), so a real .env in the context would be +# read at build time. PUBLIC_SENTRY_DSN in particular flips autoUploadSourceMaps +# on, and the build then fails looking for a SENTRY_AUTH_TOKEN that is not there. +# Every PUBLIC_ value this app reads comes from $env/dynamic/public, which is +# resolved at RUNTIME, so nothing here needs an .env to build correctly. +.env +.env.* + +# Install and build outputs. Regenerated inside the image; copying the host's +# would also drop a linux-x64 image on top of whatever the host built. +node_modules +.svelte-kit +build + +# Never needed by a build +.git +.gitignore +.vscode +.idea +.playwright-mcp +coverage +*.log +.DS_Store +Thumbs.db diff --git a/frontend/.env.example b/frontend/.env.example index 440c9e51..51ff1a5c 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1 +1,29 @@ -VITE_API_BASE_URL=https://betaapicsa.tradhox.com/api +# Google OAuth (client_id only - secret is on backend) +GOOGLE_CLIENT_ID="your-google-client-id-here" +GOOGLE_LOGIN_DOMAIN="http://localhost:5181" + +# Django API URL (PUBLIC_ prefix makes it available in both server and browser) +PUBLIC_DJANGO_API_URL=http://localhost:8000 + +# Sentry (only set in production, omit locally to disable Sentry) +# PUBLIC_SENTRY_DSN="https://your-dsn.ingest.sentry.io/your-project" + +# Environment +NODE_ENV=development + +# Relayed visitor addresses (optional locally; deploy-frontend.sh requires them +# in hosted production). Several pages reach the API from this server; with +# RELAY_SECRET set they also send the visitor's address, signed with it, so +# each visitor gets their own per-IP throttle bucket and recorded address. Must equal RELAY_SECRET on the Django +# API and on bottlecrm.io. Private: never prefix it with PUBLIC_. +# RELAY_SECRET= +# +# Behind nginx in production, adapter-node only knows the visitor if told which +# header nginx writes. Without these, getClientAddress() is nginx itself. +# ADDRESS_HEADER=X-Forwarded-For +# XFF_DEPTH=1 +# +# adapter-node binds 0.0.0.0 by default. Bind it to loopback in production so +# only nginx can reach it: reachable directly, a caller writes its own +# X-Forwarded-For and the relay signs it as the visitor. +# HOST=127.0.0.1 diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf36..af1577b8 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,24 +1,30 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - node_modules -dist -dist-ssr -*.local -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS .DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* + +generated/* +src/generated/* +.github/prompts/* + +# Sentry Config File +.env.sentry-build-plugin diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 00000000..b6f27f13 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json deleted file mode 100644 index 12550782..00000000 --- a/frontend/.oxlintrc.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "./node_modules/oxlint/configuration_schema.json", - "plugins": ["react", "oxc"], - "rules": { - "react/rules-of-hooks": "error", - "react/only-export-components": ["warn", { "allowConstantExport": true }] - } -} diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 00000000..66354938 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,14 @@ +# Package Managers +package-lock.json +pnpm-lock.yaml +yarn.lock +bun.lock +bun.lockb + +# Local session artifacts +.remember/ + +# Generated API schema. drf-spectacular writes YAML, and the file name says +# .json, so prettier picks the JSON parser and dies on line 1 instead of +# reporting a formatting diff. That aborts `pnpm run lint` before eslint runs. +openapi.json diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 00000000..ef3063bc --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,16 @@ +{ + "useTabs": false, + "tabWidth": 2, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], + "overrides": [ + { + "files": "*.svelte", + "options": { + "parser": "svelte" + } + } + ] +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 00000000..bb6717f6 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,87 @@ +# syntax=docker/dockerfile:1 +# +# One file, two outputs: the `dev` stage docker-compose runs, and the default +# `runtime` stage a release publishes. They were going to be two files, which is +# how the pnpm pin in here already drifted from the one in .github/workflows +# (11.17.0 against 10). A single base stage means there is one version to bump. +# +# RUNTIME CONFIGURATION +# +# Every value this app reads from the environment is declared in src/env.js and +# read per process at startup, not at build time. So one image serves any +# deployment and nothing below bakes a hostname in: +# +# PUBLIC_DJANGO_API_URL required. The Django API origin, no trailing /api. +# PUBLIC_SENTRY_DSN optional. Unset disables Sentry (`enabled: !!dsn`). +# PORT defaults to 3000. HOST already defaults to 0.0.0.0. +# PROTOCOL_HEADER only for plain http. See below. +# BODY_SIZE_LIMIT set to 6M below. adapter-node 6 defaults to 512K, which +# would refuse a spreadsheet import (the API caps files +# at 5 MB) with a plain-text 413. +# +# The origin check is the one that fails quietly. SvelteKit compares the Origin +# header of every form POST with the origin the server thinks it serves, which +# adapter-node 6 builds from the request's Host header and the scheme `https`. +# (The ORIGIN variable that used to override this was removed in SvelteKit 3, +# so setting it does nothing.) Behind a TLS proxy that forwards Host, such as +# nginx with `proxy_set_header Host $host`, that is already right. If users +# reach the app over plain http, set PROTOCOL_HEADER=x-forwarded-proto and have +# the proxy send that header. Otherwise every form POST is answered `403 +# Cross-site POST form submissions are forbidden` while GETs still work, so the +# app looks healthy while all its form actions reject. + +FROM node:24-slim AS base +# Pinned so image builds are reproducible. +RUN corepack enable && corepack prepare pnpm@11.17.0 --activate +WORKDIR /app + +# Manifests only, so this layer caches on the lockfile rather than on source. +# pnpm-workspace.yaml carries the install settings (onlyBuiltDependencies, +# allowBuilds) that pnpm 10+ no longer reads from package.json. Without it +# @sentry/cli's postinstall is silently skipped and its binary never lands. +FROM base AS deps +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ +RUN pnpm install --frozen-lockfile + + +# The compose target. Source and node_modules arrive as mounts, so this stage +# deliberately copies no source of its own. +FROM deps AS dev +EXPOSE 5181 +CMD ["pnpm", "dev", "--host", "0.0.0.0"] + + +FROM deps AS builder +COPY . . +RUN pnpm build + + +# Runtime dependencies only. `build/` is NOT self-contained: SvelteKit keeps +# instrumentation.server.js external so OpenTelemetry can hook the process +# before anything else loads, and it imports @sentry/sveltekit by name. Without +# this stage the server dies at boot on ERR_MODULE_NOT_FOUND. +# +# --ignore-scripts because the `prepare` script shells out to svelte-kit, which +# is a devDependency and therefore absent here. package.json already tolerates +# that with `|| echo ''`; skipping it outright keeps the failure out of the log. +FROM base AS prod-deps +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ +RUN pnpm install --prod --frozen-lockfile --ignore-scripts + + +FROM node:24-slim AS runtime +WORKDIR /app +ENV NODE_ENV=production +ENV BODY_SIZE_LIMIT=6M + +COPY --from=prod-deps --chown=node:node /app/node_modules ./node_modules +COPY --from=builder --chown=node:node /app/build ./build +# The server reads its own name and version from here on startup. +COPY --chown=node:node package.json ./ + +# Present in the node images already. The server binds 3000, so it needs no +# privileged port and no reason to be root. +USER node + +EXPOSE 3000 +CMD ["node", "build/index.js"] diff --git a/frontend/README.md b/frontend/README.md index d937833b..38b3407a 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,16 +1,41 @@ -# React + Vite +# BottleCRM web app -This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. +The SvelteKit (Svelte 5) web client for BottleCRM. It has no database of its own: every page reads +and writes through the Django REST API in [`../backend`](../backend), the same API the Flutter app +in [`../mobile`](../mobile) uses. For what BottleCRM is, see the [project README](../README.md). -Currently, two official plugins are available: +## Run it locally -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) +Start the backend first (see [Development setup](../docs/contributing/development-setup.md)), then: -## React Compiler +```bash +cd frontend +cp .env.example .env # PUBLIC_DJANGO_API_URL points at the backend, http://localhost:8000 by default +pnpm install +pnpm run dev # http://localhost:5181 +``` -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). +The browser calls the API cross-origin, so the backend's `CORS_ALLOWED_ORIGINS` must include +`http://localhost:5181`. A missing entry shows up as a CORS error, not a 404. -## Expanding the Oxlint configuration +## Checks -If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and Oxlint's TypeScript related rules in your project. +```bash +pnpm run check # svelte-check type checking +pnpm run lint # prettier + eslint +pnpm run test # vitest +pnpm run build # production build (adapter-node) +``` + +## Access control + +Org membership and role (`USER` or `ADMIN`) come from the server, and the backend is the only place +they are enforced; anything the UI hides is a convenience, not a control. Platform super admin is +the `is_superuser` flag on the user record, granted explicitly and never derived from an email +address. + +## More + +- [Manual setup](../docs/getting-started/manual-setup.md) and [Docker quick start](../docs/getting-started/docker-quick-start.md) +- [Contributing](../CONTRIBUTING.md) +- License: [MIT](../LICENSE) diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 00000000..f027956f --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://next.shadcn-svelte.com/schema.json", + "style": "default", + "tailwind": { + "config": "", + "css": "src/app.css", + "baseColor": "neutral" + }, + "aliases": { + "components": "$lib/components", + "utils": "$lib/utils", + "ui": "$lib/components/ui", + "hooks": "$lib/hooks" + }, + "typescript": false +} diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 00000000..6b83ff0d --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,31 @@ +import prettier from 'eslint-config-prettier'; +import js from '@eslint/js'; +import { includeIgnoreFile } from '@eslint/compat'; +import svelte from 'eslint-plugin-svelte'; +import globals from 'globals'; +import { fileURLToPath } from 'node:url'; + +const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); + +export default [ + includeIgnoreFile(gitignorePath), + js.configs.recommended, + ...svelte.configs.recommended, + prettier, + ...svelte.configs.prettier, + { + languageOptions: { + globals: { ...globals.browser, ...globals.node } + } + }, + { + rules: { + 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + 'no-useless-assignment': 'warn', + 'svelte/require-each-key': 'warn', + 'svelte/no-navigation-without-resolve': 'warn', + 'svelte/prefer-svelte-reactivity': 'warn', + 'svelte/prefer-writable-derived': 'warn' + } + } +]; diff --git a/frontend/index.html b/frontend/index.html deleted file mode 100644 index 11c834a0..00000000 --- a/frontend/index.html +++ /dev/null @@ -1,16 +0,0 @@ - - -
- - - -No KYC documents submitted yet.
- )} - - {/* Lightbox / Preview Modal Overlay */} - {previewDoc && ( -Preview is not supported directly for this file type ({previewDoc.filename}).
- -Manage sellers, customers, logistics & payments
-+ {description} +
+ + ++ Any column names work. You'll check how they match before anything is imported. +
+ {#if error} ++ Starting from scratch? + +
+ {:else if step === 'match' && preview && file} ++ We couldn't match any columns. Make sure the first row of your sheet holds the column names. +
+ {/if} + +| Column in your file | +Sample values | +Import as | +
|---|---|---|
| {name} | ++ {#if c.samples.length}{c.samples.join(' · ')}{:else}No values{/if} + | ++ + | +
Updating the preview
+ {/if} + {#each droppedLines(preview.dropped, preview.fields) as line (line)} +{line}
+ {/each} + {#if preview.skipped.length} ++ {rows(preview.skipped.length - SKIPPED_SHOWN)} more are in the download. +
+ {/if} ++ Some rows were skipped for a missing first or last name. A lead needs only one of the two, + so this file could go in as leads instead. + {#if onUseLeads} + + {/if} +
+ {/if} +{summaryLine(nouns, preview)}
+
+
+
{line}
+ {/each} + {#if result.skipped?.length} + {@const skipped = result.skipped} +{rows(skipped.length)} skipped.
+CSV format
+
+ Required headers: name,
+ status,
+ priority. Optional:
+ description, case_type, account_name, contact_emails, assigned_emails, team_names, tags
+ (semicolon-separated), closed_on (YYYY-MM-DD).
+
| # | +Name | +Status | +Priority | +Account | +
|---|---|---|---|---|
| {row.row} | +{row.name} | +{row.status} | +{row.priority} | ++ {row.account_id ? '✓' : '—'} + | +
+ {preview.errors.length} error{preview.errors.length === 1 ? '' : 's'}, fix the CSV + before importing +
+ +| Row | +Field | +Problem | +
|---|---|---|
| {err.row} | +{err.field} | +{err.message} | +
+ Server rejected {commitErrors.length} row{commitErrors.length === 1 ? '' : 's'} during + import. The file may have changed since preview +
+ +| Row | +Field | +Problem | +
|---|---|---|
| {err.row} | +{err.field} | +{err.message} | +
+ Imported {commitResult.created} ticket{commitResult.created === 1 ? '' : 's'} +
+The tickets list has been refreshed.
+