diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 5e982c6a..00000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Deploy to Cloudflare - -on: - push: - branches: - - main - - develop - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v3 - with: - node-version: 24 - - - name: Install pnpm - run: npm install -g pnpm - - - run: pnpm install - - run: pnpm run build - - - name: Deploy to Staging - if: github.ref_name == 'develop' - uses: cloudflare/wrangler-action@v3 - with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - command: deploy --env staging - - - name: Deploy to Production - if: github.ref_name == 'main' - uses: cloudflare/wrangler-action@v3 - with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - command: deploy --env prod diff --git a/README.md b/README.md index 0df5d906..ec6fc0e9 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# Goteo v4 Web +# DTF's Goteo v4 Web client -Frontend for the [Goteo](https://goteo.org) crowdfunding platform — built on Astro 7 + Svelte 5 + TailwindCSS 4, deployed on Cloudflare Workers. +Frontend for the [DTF](https://democratictech.fund) crowdfunding site — built on Astro 7 + Svelte 5 + TailwindCSS 4, deployed on Cloudflare Workers. -> **NOTE**: This application is a client of the [Goteo v4 API](https://github.com/goteo/org.goteo.api). You need a running v4 API instance and an OAuth client registered in it before this web app will work. +> **NOTE**: This application is a client of the [Goteo v4 API](https://github.com/goteo/v4-api). You need a running v4 API instance and an OAuth client registered in it before this web app will work. ## Installation @@ -11,8 +11,8 @@ This application requires [Node.js](https://nodejs.org/en/download) (v22.12 or l ### 1. Clone or download this repository. ```shell -git clone https://github.com/goteo/org.goteo.www -cd org.goteo.www +git clone https://git.coopcloud.tech/DemocraticTech.Fund/crowd-fund-web +cd crowd-fund-web ``` ### 2. Install the dependencies. @@ -63,7 +63,11 @@ The dev server runs on the Cloudflare Workers runtime (`workerd`), the same one | `pnpm preview` | Preview production build locally | | `pnpm format` | ESLint fix + Prettier write | | `pnpm check` | Prettier format check | -| `pnpm openapi` | Regenerate OpenAPI SDK from live API spec | +| `pnpm check:astro` | Type check `.astro` files (`astro check`) | +| `pnpm check:svelte` | Type check `.svelte` files (`svelte-check`) | +| `pnpm db:migrate` | Apply D1 migrations to the local database | +| `pnpm db:seed` | Seed the local D1 database | +| `pnpm sdk` | Regenerate OpenAPI SDK from live API spec | | `pnpm storybook` | Storybook dev server at `localhost:6006` | | `pnpm build-storybook` | Build Storybook static output | @@ -72,14 +76,20 @@ The dev server runs on the Cloudflare Workers runtime (`workerd`), the same one The TypeScript API client under `src/openapi/client/` is generated from the live API's OpenAPI spec. Regenerate it whenever the API spec changes (requires the API running and `PUBLIC_API_URL` / `PUBLIC_API_VERSION` set): ```shell -pnpm openapi +pnpm sdk ``` This fetches the spec from `$PUBLIC_API_URL/$PUBLIC_API_VERSION/docs.json`. Commit the generated files together with any config change. ## Testing -Component and unit tests run on [Vitest](https://vitest.dev/) through the Storybook test addon — launch Storybook to develop and visually check components: +Component and unit tests run on [Vitest](https://vitest.dev/) through the Storybook test addon. There is no `pnpm test` script; run them with: + +```shell +pnpm exec vitest +``` + +Launch Storybook to develop and visually check components: ```shell pnpm storybook @@ -89,6 +99,8 @@ pnpm storybook ```shell pnpm format # Fix formatting (ESLint + Prettier) +pnpm check:astro # Type check .astro files +pnpm check:svelte # Type check .svelte files ``` ## Debugging @@ -108,7 +120,7 @@ This almost always means the app cannot reach the API or the OAuth client is mis The generated SDK is out of date. Regenerate it (API must be running): ```shell -pnpm openapi +pnpm sdk ``` ### 3. Errors about missing Node.js modules (`fs`, `path`, `os`, `child_process`, …). @@ -126,42 +138,50 @@ The dev server and production both run on the Cloudflare Workers runtime, which | Language | TypeScript 5 | | Runtime | Cloudflare Workers (also supports Node) | | API client | `@hey-api/client-fetch` (generated OpenAPI) | -| Testing | Vitest (unit), Cypress (E2E), Storybook | +| Testing | Vitest, Storybook | ## Routes All user-facing pages live under `src/pages/[...locale]/` (locale prefix: `es`, `en`, `ca`). -| Route | Page | -| -------------------------- | -------------------------- | -| `/` | Home | -| `/search` | Project search | -| `/project/[idOrSlug]` | Project detail | -| `/project/[idOrSlug]/edit` | Project editor (auth) | -| `/create/project` | Create project (auth) | -| `/checkout` | Checkout cart | -| `/checkout/payment` | Payment | -| `/checkout/post-payment` | Post-payment confirmation | -| `/checkout/verify` | Payment verification | -| `/checkout/wallet` | Wallet confirmation | -| `/login` | Login | -| `/login/callback` | OAuth2 callback | -| `/logout` | Logout | -| `/register` | Register | -| `/me` | My profile (auth) | -| `/user/[idOrHandle]` | Public user profile | -| `/about` | About | -| `/static/[fileName]` | CMS static content | -| `/admin/charges` | Admin contributions (auth) | -| `/403`, `/404`, `/500` | Error pages | +| Route | Page | +| -------------------------- | ------------------------------ | +| `/` | Home | +| `/search` | Project search | +| `/project/[idOrSlug]` | Project detail | +| `/project/[idOrSlug]/edit` | Project editor (auth) | +| `/create/project` | Create project (auth) | +| `/checkout` | Checkout cart | +| `/checkout/payment` | Payment | +| `/checkout/post-payment` | Post-payment confirmation | +| `/checkout/verify` | Payment verification | +| `/checkout/wallet` | Wallet confirmation | +| `/login` | Login | +| `/login/callback` | OAuth2 callback | +| `/logout` | Logout | +| `/register` | Register | +| `/me` | My profile (auth) | +| `/me/manage` | Manage my projects (auth) | +| `/user/[idOrHandle]` | Public user profile | +| `/about` | About | +| `/static/[fileName]` | CMS static content | +| `/admin` | Admin dashboard (admin) | +| `/admin/charges` | Admin contributions (admin) | +| `/admin/projects` | Admin projects (admin) | +| `/admin/users` | Admin users (admin) | +| `/admin/home` | Home hero & highlights (admin) | +| `/admin/comm` | Banners & newsletter (admin) | +| `/403`, `/404`, `/500` | Error pages | API endpoints under `src/pages/api/`: -| Endpoint | Purpose | -| ------------------------ | ---------------------------- | -| `/api/relay/[...path]` | Authenticated API proxy | -| `/api/upload/preupload` | S3 pre-signed URL generation | -| `/api/upload/postupload` | S3 post-upload validation | +| Endpoint | Purpose | +| ------------------------ | ----------------------------------------- | +| `/api/relay/[...path]` | Authenticated API proxy | +| `/api/session` | Read session (GET), password login (POST) | +| `/api/upload/preupload` | S3 pre-signed URL generation | +| `/api/upload/postupload` | S3 post-upload validation | +| `/api/upload/delete` | Delete an uploaded object | ## Project Structure @@ -171,9 +191,9 @@ src/ ├── auth/ # JWT sessions, OAuth2 tokens, refresh logic ├── components/ # Svelte components (PascalCase filenames) │ ├── library/ # Reusable UI primitives + Storybook stories -│ ├── Admin/ # Admin-specific components +│ ├── admin/ # Admin-specific components │ ├── icons/ # Icon SVG components -│ └── [Feature]/ # Feature-scoped (project/, profile/, Checkout/, etc.) +│ └── [feature]/ # Feature-scoped (project/, profile/, checkout/, etc.) ├── firewall/ # Route access control rules ├── i18n/ # Translations (es/en/ca) + t() store ├── layouts/ # Astro layout components @@ -182,15 +202,13 @@ src/ ├── pages/ # Astro routes │ ├── [...locale]/ # All user-facing pages under locale prefix │ └── api/ # Server-side API endpoints +├── repositories/ # Persistence: drafts (Dexie/IndexedDB), banners, highlights, home hero (D1) ├── services/ # Business logic & API call wrappers ├── stores/ # Svelte stores (client-side state) -│ └── drafts/ # Project draft persistence (Dexie/IndexedDB) -├── stories/ # Full-page Storybook stories ├── styles/ # Global CSS + Tailwind theme tokens -├── svgs/ # SVG Svelte components ├── types/ # Shared TypeScript interfaces -└── utils/ # Pure helper functions - └── drafts/ # Draft DB repository helpers +├── utils/ # Helper functions (checkouts/, media/ subfolders) +└── validation/ # Project/reward/collab/register validators ``` ## Environment variables @@ -215,10 +233,9 @@ Full list lives in [`.env.example`](.env.example). Grouped by purpose: | `BASIC_AUTH_USERNAME` / `BASIC_AUTH_PASSWORD` | | Credentials for the Basic auth layer | | `OBJECT_STORAGE_*` | | S3-compatible storage (access key, secret, region, endpoint, bucket) for media upload | -## CI / Deployment +## CI -| Workflow | Trigger | Action | -| -------------------- | ----------------- | --------------------------------- | -| `deploy.yml` | Push to `main` | Deploy to Cloudflare (production) | -| `deploy.yml` | Push to `develop` | Deploy to Cloudflare (staging) | -| `prettier-check.yml` | PR / push | Prettier format check | +| Workflow | Trigger | Action | +| -------------------- | -------------------------------------------- | -------------------------------- | +| `prettier-check.yml` | PR to `main` / `develop` | Prettier format check | +| `crowdin-upload.yml` | Push to `develop` touching `es.json`, manual | Upload source strings to Crowdin | diff --git a/SKILL-template.md b/SKILL-template.md index dffec195..afb8e54c 100644 --- a/SKILL-template.md +++ b/SKILL-template.md @@ -7,46 +7,22 @@ description: Provides a web GUI to interact with the Goteo v4 API and adds tools ## Stack -Astro 7 + Svelte 5 + TailwindCSS 4 + TypeScript 5, deployed on Cloudflare Workers (also supports Node adapter). Requires Node 22.12+. +Astro 7 + Svelte 5 + TailwindCSS 4 + TypeScript 5, deployed on Cloudflare Workers. Requires Node 22.12+. Always use `pnpm` (never npm/npx/yarn). -## Folder structure +`svelte.config.js` only sets `preprocess: vitePreprocess()` — Astro reads its Svelte config from `astro.config.mjs`, but `svelte-check` reads this file, so it must stay in sync. -```bash -src/ -├── actions/ # Astro server actions (form mutations) -├── auth/ # JWT sessions, OAuth2 tokens, refresh logic -├── components/ # Svelte components (PascalCase filenames) -│ ├── library/ # Reusable UI primitives, by category: -│ │ # buttons/, cards/, dropdown/, feedback/, inputs/, -│ │ # layout/, share/, tags/, theme/, typography/ -│ ├── icons/ # Icon SVG components, by category: -│ │ # actions/, commerce/, filters/, media/, navigation/, -│ │ # payment/, social/, status/, user/ -│ ├── admin/ # Admin-specific components -│ └── [feature]/ # Feature-scoped, lowercase: auth/, checkout/, errorpage/, -│ # footer/, header/, hero/, home/, player/, profile/, -│ # project/ (+ project/edit/), search/ -├── firewall/ # Route access control rules -├── i18n/ # Translations (es/en/ca) + t() store -│ └── locales/ # es.json / en.json / ca.json -├── layouts/ # Astro layout components -├── middleware/ # Astro request middleware -├── openapi/ # OpenAPI SDK — see SDK section below -│ ├── client/ # Generated SDK (do not edit) -│ └── plugins/ # Custom codegen plugins -├── pages/ # Astro routes -│ ├── [...locale]/ # All user-facing pages under locale prefix -│ └── api/ # Server-side API endpoints -├── services/ # Business logic & API call wrappers -├── stores/ # Svelte stores (client-side state) -│ └── drafts/ # Project draft persistence (Dexie/IndexedDB) -├── styles/ # Global CSS -├── types/ # Shared TypeScript interfaces -└── utils/ # Pure helper functions - └── drafts/ # Draft DB repository helpers -``` +Cloudflare D1 database: migrations in `db/migrations/`, seed in `db/seed.sql`. Locally: `pnpm db:migrate`, `pnpm db:seed`. -Root config files: `astro.config.mjs`, `openapi-ts.config.ts`, `wrangler.toml`, `vitest.config.ts`. +## Where new code goes + +| Folder | Contents | +| ------------------- | ------------------------------------------------------------------------- | +| `src/repositories/` | Persistence: drafts (Dexie/IndexedDB), banners, highlights, homeHero (D1) | +| `src/services/` | Business logic & API call wrappers | +| `src/utils/` | Helper functions (`checkouts/`, `media/` subfolders) | +| `src/validation/` | Project/reward/collab/register validators | +| `src/stores/` | Svelte stores (client-side state) | +| `src/actions/` | Astro server actions | ## Conventions @@ -56,8 +32,9 @@ Root config files: `astro.config.mjs`, `openapi-ts.config.ts`, `wrangler.toml`, | ----------------- | ------------------------------- | ---------------------------------- | | Svelte components | `PascalCase.svelte` | `ProjectCard.svelte` | | Services | `camelCaseService.ts` | `projectsService.ts` | -| Stores | `camelCase.ts` | `searchStore.ts` | -| Utils | `camelCase.ts` | `cachedFetch.ts` | +| Stores | `camelCaseStore.ts` | `draftsStore.ts` | +| Repositories | `camelCases.ts` | `drafts.ts` | +| Utils | `camelCase.ts` | `extractId.ts` | | Types/interfaces | `camelCase.ts` | `campaign.ts` | | Pages | kebab-case under `[...locale]/` | `[...locale]/create/project.astro` | @@ -70,14 +47,7 @@ export const projectsService = new ProjectsService(); ### Imports -No path aliases — use relative paths: - -```typescript -import { apiProjectsGetCollection } from "../openapi/client/sdk.gen"; -import type { Project } from "../openapi/client/types.gen"; -``` - -Import sorting is enforced by ESLint (alphabetical). Always separate type imports with `import type`. +No path aliases — use relative paths. Import sorting is enforced by ESLint (alphabetical). Always separate type imports with `import type`. ### Styling @@ -87,8 +57,6 @@ TailwindCSS utility classes only. Avoid scoped ` + + + democratic. + + tech + + .fund + + \ No newline at end of file diff --git a/public/images/brand/goteo-drop.svg b/public/images/brand/goteo-drop.svg new file mode 100644 index 00000000..6e173a31 --- /dev/null +++ b/public/images/brand/goteo-drop.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/home/archicare-purple.png b/public/images/home/archicare-purple.png new file mode 100644 index 00000000..2b84fe06 Binary files /dev/null and b/public/images/home/archicare-purple.png differ diff --git a/public/images/home/blog.png b/public/images/home/blog.png new file mode 100644 index 00000000..536888cc Binary files /dev/null and b/public/images/home/blog.png differ diff --git a/public/images/home/distrib-transparent.png b/public/images/home/distrib-transparent.png new file mode 100644 index 00000000..0e1eacf4 Binary files /dev/null and b/public/images/home/distrib-transparent.png differ diff --git a/public/images/home/megafono.png b/public/images/home/megafono.png new file mode 100644 index 00000000..a3692791 Binary files /dev/null and b/public/images/home/megafono.png differ diff --git a/public/images/home/unions.png b/public/images/home/unions.png new file mode 100644 index 00000000..74be39ed Binary files /dev/null and b/public/images/home/unions.png differ diff --git a/public/images/partners/COMMONSNETWORK.jpeg b/public/images/partners/COMMONSNETWORK.jpeg new file mode 100644 index 00000000..9dce60a4 Binary files /dev/null and b/public/images/partners/COMMONSNETWORK.jpeg differ diff --git a/public/images/partners/ECOMMONS.svg b/public/images/partners/ECOMMONS.svg new file mode 100644 index 00000000..dc5a7d93 --- /dev/null +++ b/public/images/partners/ECOMMONS.svg @@ -0,0 +1,60 @@ + + + +eCommons diff --git a/public/images/partners/FKI.svg b/public/images/partners/FKI.svg new file mode 100644 index 00000000..ea75d023 --- /dev/null +++ b/public/images/partners/FKI.svg @@ -0,0 +1,148 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/partners/MTES.png b/public/images/partners/MTES.png deleted file mode 100644 index c0d8dad9..00000000 Binary files a/public/images/partners/MTES.png and /dev/null differ diff --git a/public/images/partners/NGEU.png b/public/images/partners/NGEU.png deleted file mode 100644 index 24481daf..00000000 Binary files a/public/images/partners/NGEU.png and /dev/null differ diff --git a/public/images/partners/OPENTECH.png b/public/images/partners/OPENTECH.png new file mode 100644 index 00000000..7faa1f6e Binary files /dev/null and b/public/images/partners/OPENTECH.png differ diff --git a/public/images/partners/PLATFORMCOOPS.svg b/public/images/partners/PLATFORMCOOPS.svg new file mode 100644 index 00000000..0131a6c7 --- /dev/null +++ b/public/images/partners/PLATFORMCOOPS.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/partners/PLATONIQ.png b/public/images/partners/PLATONIQ.png new file mode 100644 index 00000000..c5d253ab Binary files /dev/null and b/public/images/partners/PLATONIQ.png differ diff --git a/public/images/partners/PRTR.png b/public/images/partners/PRTR.png deleted file mode 100644 index 42d0ba13..00000000 Binary files a/public/images/partners/PRTR.png and /dev/null differ diff --git a/public/images/partners/alda-europe.svg b/public/images/partners/alda-europe.svg deleted file mode 100644 index cef31322..00000000 --- a/public/images/partners/alda-europe.svg +++ /dev/null @@ -1,125 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/images/partners/alianza-por-el-clima.webp b/public/images/partners/alianza-por-el-clima.webp deleted file mode 100644 index a373df91..00000000 Binary files a/public/images/partners/alianza-por-el-clima.webp and /dev/null differ diff --git a/public/images/partners/ampliando-democracia.jpg b/public/images/partners/ampliando-democracia.jpg deleted file mode 100644 index 66715f49..00000000 Binary files a/public/images/partners/ampliando-democracia.jpg and /dev/null differ diff --git a/public/images/partners/ampliando-democracia.png b/public/images/partners/ampliando-democracia.png deleted file mode 100644 index ce5218c0..00000000 Binary files a/public/images/partners/ampliando-democracia.png and /dev/null differ diff --git a/public/images/partners/funding-partner-1.svg b/public/images/partners/funding-partner-1.svg deleted file mode 100644 index ab367e5b..00000000 --- a/public/images/partners/funding-partner-1.svg +++ /dev/null @@ -1,576 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/images/partners/funding-partner-2.svg b/public/images/partners/funding-partner-2.svg deleted file mode 100644 index 4e3419d9..00000000 --- a/public/images/partners/funding-partner-2.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/public/images/partners/funding-partner-3.svg b/public/images/partners/funding-partner-3.svg deleted file mode 100644 index d9ff00ff..00000000 --- a/public/images/partners/funding-partner-3.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/public/images/partners/xes.png b/public/images/partners/xes.png deleted file mode 100644 index 53b6d1d7..00000000 Binary files a/public/images/partners/xes.png and /dev/null differ diff --git a/public/images/project/09_shelf_ARCHICARE.svg b/public/images/project/09_shelf_ARCHICARE.svg new file mode 100644 index 00000000..855d8897 --- /dev/null +++ b/public/images/project/09_shelf_ARCHICARE.svg @@ -0,0 +1,8 @@ + + + + + +ARCHICARE + + \ No newline at end of file diff --git a/public/images/project/10_shelf_DisInfra.svg b/public/images/project/10_shelf_DisInfra.svg new file mode 100644 index 00000000..e99563f0 --- /dev/null +++ b/public/images/project/10_shelf_DisInfra.svg @@ -0,0 +1,11 @@ + + + + + + + +DISTRIBUTED +INFRASTRUCTURES + + \ No newline at end of file diff --git a/src/actions/banners.ts b/src/actions/banners.ts index 5622c1b8..8a6d29d0 100644 --- a/src/actions/banners.ts +++ b/src/actions/banners.ts @@ -1,5 +1,5 @@ -import { z } from "astro/zod"; import { ActionError, defineAction } from "astro:actions"; +import { z } from "zod"; import { bannerRepository } from "../repositories/banners"; diff --git a/src/actions/hero.ts b/src/actions/hero.ts index 855f5b03..b6b03ef9 100644 --- a/src/actions/hero.ts +++ b/src/actions/hero.ts @@ -2,32 +2,190 @@ import { ActionError, defineAction } from "astro:actions"; import { z } from "zod"; import { homeHeroRepository } from "../repositories/homeHero"; +import { HOME_HERO_TABLE, translationRepository } from "../repositories/translations"; import { startOfDay } from "../utils/dates"; -// Astro turns any empty form field into null unless the validator is optional, -// so every field the admin may leave blank has to be declared as such. -const optionalText = z.string().optional(); +// The admin may translate the hero into ANY recognized language, not only the +// locales the UI is available in. A bare ISO-like code (e.g. "de", "pt", "eu") +// is enough; only well-formed codes are persisted. +const LANGUAGE_CODE_PATTERN = /^[a-z]{2,8}([-_][a-z0-9]{2,8})*$/; +// Astro turns any empty form field into null unless the validator is optional, +// so every field the admin may leave blank has to be declared as such. The CTA +// fields are optional, but the form always submits them as trimmed strings, so +// an empty string has to be accepted as "no value". +const optionalText = z.string().optional(); const optionalUrl = z - .union([z.url(), z.string().startsWith("/")], "pages.admin.home.hero.errors.invalidUrl") + .string() + .refine((value) => { + if (value === "" || value.startsWith("/") || value.startsWith("#")) { + return true; + } + + try { + const url = new URL(value); + + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } + }, "pages.admin.home.hero.errors.invalidUrl") .optional(); const scheduledDate = z.coerce .date() .refine((date) => date >= startOfDay(new Date()), "system.constraint.date.greaterThan"); +// The same translatable fields exist for the base row and for each added locale. +const baseFields = z.object({ + title: z.string("system.constraint.text.notEmpty").min(1), + content: z.string("system.constraint.text.notEmpty").min(1), + primaryCtaText: optionalText, + primaryCtaLink: optionalUrl, + secondaryCtaText: optionalText, + secondaryCtaLink: optionalUrl, +}); + +const translationFields = z.object({ + title: z.string().optional(), + content: z.string().optional(), + primaryCtaText: optionalText, + primaryCtaLink: optionalUrl, + secondaryCtaText: optionalText, + secondaryCtaLink: optionalUrl, +}); + +const translationsSchema = z.record(z.string(), translationFields); + +const TRANSLATION_COLUMNS: Record, string> = { + title: "title", + content: "content", + primaryCtaText: "primary_cta_text", + primaryCtaLink: "primary_cta_link", + secondaryCtaText: "secondary_cta_text", + secondaryCtaLink: "secondary_cta_link", +}; + +// The locale written into the base row. The editor sends the authoring +// language (the platform language while present, otherwise the first selected +// one) and defaults to the platform language when absent. +const languageField = z.string().optional(); + +const heroInputSchema = z.object({ + ...baseFields.shape, + mediaUrl: optionalUrl, + mediaType: optionalText, + startsAt: scheduledDate, + language: languageField, + // JSON payload with the translations of every added locale. + translations: z.string().optional(), +}); + +type Translator = (key: string, vars?: Record) => string; + +function parseTranslations( + raw: string | undefined, + t: Translator, +): Record> { + if (!raw) { + return {}; + } + + let parsed: unknown; + + try { + parsed = JSON.parse(raw); + } catch { + throw new ActionError({ + code: "BAD_REQUEST", + message: t("pages.admin.home.hero.errors.invalidTranslations"), + }); + } + + const result = translationsSchema.safeParse(parsed); + + if (!result.success) { + throw new ActionError({ + code: "BAD_REQUEST", + message: t("pages.admin.home.hero.errors.invalidTranslations"), + }); + } + + return Object.fromEntries( + Object.entries(result.data).filter(([locale]) => LANGUAGE_CODE_PATTERN.test(locale)), + ); +} + +function translationColumnValues( + fields: z.infer, +): Record { + const values: Record = {}; + + for (const [field, value] of Object.entries(fields)) { + const text = value?.trim(); + + if (text) { + values[TRANSLATION_COLUMNS[field as keyof typeof TRANSLATION_COLUMNS]] = text; + } + } + + return values; +} + +async function storeTranslations( + heroId: number, + translations: Record>, +): Promise { + for (const [locale, fields] of Object.entries(translations)) { + const values = translationColumnValues(fields); + + if (Object.keys(values).length === 0) { + continue; + } + + await translationRepository.set(HOME_HERO_TABLE, heroId, locale, values); + } +} + export const createHomeHero = defineAction({ accept: "form", - input: z.object({ - title: z.string("system.constraint.text.notEmpty").min(1), - content: z.string("system.constraint.text.notEmpty").min(1), - primaryCtaText: optionalText, - primaryCtaLink: optionalUrl, - secondaryCtaText: optionalText, - secondaryCtaLink: optionalUrl, - mediaUrl: optionalUrl, - mediaType: optionalText, - startsAt: scheduledDate, + input: heroInputSchema, + handler: async (input, context) => { + const { session, t } = context.locals; + + // Actions are posted to /_actions/*, which the /admin firewall rule does not + // match, so the role has to be checked here. + if (!session?.user.roles?.includes("ROLE_ADMIN")) { + throw new ActionError({ + code: "FORBIDDEN", + message: t("pages.admin.home.hero.errors.forbidden"), + }); + } + + const translations = parseTranslations(input.translations, t); + + const heroId = await homeHeroRepository.create({ + language: input.language || import.meta.env.PUBLIC_DEFAULT_LANGUAGE, + title: input.title, + content: input.content, + primaryCtaText: input.primaryCtaText || null, + primaryCtaLink: input.primaryCtaLink || null, + secondaryCtaText: input.secondaryCtaText || null, + secondaryCtaLink: input.secondaryCtaLink || null, + mediaUrl: input.mediaUrl || null, + mediaType: input.mediaType || null, + startsAt: input.startsAt, + dateCreated: new Date(), + }); + + await storeTranslations(heroId, translations); + }, +}); + +export const updateHomeHero = defineAction({ + accept: "form", + input: heroInputSchema.extend({ + id: z.coerce.number().int().positive(), }), handler: async (input, context) => { const { session, t } = context.locals; @@ -41,7 +199,10 @@ export const createHomeHero = defineAction({ }); } - await homeHeroRepository.create({ + const translations = parseTranslations(input.translations, t); + + await homeHeroRepository.update(input.id, { + language: input.language || import.meta.env.PUBLIC_DEFAULT_LANGUAGE, title: input.title, content: input.content, primaryCtaText: input.primaryCtaText || null, @@ -51,8 +212,31 @@ export const createHomeHero = defineAction({ mediaUrl: input.mediaUrl || null, mediaType: input.mediaType || null, startsAt: input.startsAt, - dateCreated: new Date(), }); + + // Replace the stored translations so locales dropped from the editor do + // not keep stale rows. Depends on the D1 FK to home_hero (CASCADE). + await translationRepository.deleteByRow(HOME_HERO_TABLE, input.id); + await storeTranslations(input.id, translations); + }, +}); + +export const getHomeHeroData = defineAction({ + handler: async (_input, context) => { + const { session, t } = context.locals; + + // Actions are posted to /_actions/*, which the /admin firewall rule does not + // match, so the role has to be checked here. + if (!session?.user.roles?.includes("ROLE_ADMIN")) { + throw new ActionError({ + code: "FORBIDDEN", + message: t("pages.admin.home.hero.errors.forbidden"), + }); + } + + return { + heroes: await homeHeroRepository.getAll(), + }; }, }); diff --git a/src/actions/highlights.ts b/src/actions/highlights.ts index 8e62818e..3d20d2eb 100644 --- a/src/actions/highlights.ts +++ b/src/actions/highlights.ts @@ -1,5 +1,5 @@ -import { z } from "astro/zod"; import { ActionError, defineAction } from "astro:actions"; +import { z } from "zod"; import { highlightRepository } from "../repositories/highlights"; diff --git a/src/actions/index.ts b/src/actions/index.ts index f59717c5..f2f4b0df 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -1,7 +1,14 @@ import { createBanner, deleteBanner } from "./banners"; -import { createHomeHero, deleteHomeHero } from "./hero"; +import { createHomeHero, deleteHomeHero, getHomeHeroData, updateHomeHero } from "./hero"; import { deleteHighlights, saveHighlights } from "./highlights"; import { payment } from "./payment"; +import { updatePersonalProfile } from "./personalProfile"; +import { + sendReviewAreaComment, + updateReviewAreaRisk, + updateReviewProjectStatus, +} from "./projectReview"; +import { updatePublicProfile } from "./publicProfile"; import { register } from "./register"; export const server = { @@ -11,6 +18,13 @@ export const server = { deleteBanner, createHomeHero, deleteHomeHero, + getHomeHeroData, + updateHomeHero, saveHighlights, deleteHighlights, + updatePublicProfile, + updatePersonalProfile, + updateReviewAreaRisk, + updateReviewProjectStatus, + sendReviewAreaComment, }; diff --git a/src/actions/personalProfile.ts b/src/actions/personalProfile.ts new file mode 100644 index 00000000..3134da59 --- /dev/null +++ b/src/actions/personalProfile.ts @@ -0,0 +1,134 @@ +import { ActionError, defineAction } from "astro:actions"; + +import { setSession } from "../auth/session.ts"; +import { + apiUsersIdOrHandleGet, + apiUsersIdPatch, + apiUsersIdpersonGet, + apiUsersIdpersonPatch, + type UserUserUpdationDto, +} from "../openapi/client/index.ts"; +import { zPersonalProfileForm } from "../validation/personalProfileValidation.ts"; + +/** + * Saves the private part of the profile: the `Person` record, the `Territory` on the `User` and + * the identity row (avatar, bio, social networks) that this section shares with the public one. + * The email is deliberately left out: changing it needs a confirmation flow the API does not + * expose here. + */ +export const updatePersonalProfile = defineAction({ + accept: "json", + input: zPersonalProfileForm, + handler: async (input, context) => { + const { t, session } = context.locals; + + if (!session) { + throw new ActionError({ + code: "UNAUTHORIZED", + message: t("system.error.unauthorized"), + }); + } + + const id = String(session.user.id); + const headers = session.token.asHttpHeaders; + const isOrganization = input.type === "organization"; + + try { + const userPatch: Partial = input; + + if (input.country) { + const currentTerritory = session.user.territory; + const newTerritory = { + country: input.country, + subLvl1: input.subLvl1 || null, + subLvl2: input.subLvl2 || null, + address: input.address.trim() || null, + }; + if ( + !currentTerritory || + currentTerritory.country !== newTerritory.country || + (currentTerritory.subLvl1 ?? null) !== newTerritory.subLvl1 || + (currentTerritory.subLvl2 ?? null) !== newTerritory.subLvl2 || + (currentTerritory.address ?? null) !== newTerritory.address + ) { + userPatch.territory = newTerritory; + } + } else if (session.user.territory?.country) { + // Country cleared in the form: do not send territory at all + userPatch.territory = undefined; + } + + let userError: unknown; + if (Object.keys(userPatch).length > 0) { + const { error } = await apiUsersIdPatch({ + path: { id }, + headers, + body: userPatch as UserUserUpdationDto, + }); + userError = error; + } + + if (userError) { + throw userError; + } + + let personError: unknown; + if (!isOrganization) { + const currentPerson = await apiUsersIdpersonGet({ path: { id }, headers }).then( + (r) => r.data, + ); + const personPatch: Record = {}; + const firstNameTrim = input.firstName.trim(); + if ((currentPerson?.firstName ?? "") !== firstNameTrim) { + personPatch.firstName = firstNameTrim; + } + const lastNameTrim = input.lastName.trim(); + if ((currentPerson?.lastName ?? "") !== lastNameTrim) { + personPatch.lastName = lastNameTrim; + } + const taxIdTrim = input.taxId.trim(); + if ((currentPerson?.taxId ?? "") !== taxIdTrim) { + personPatch.taxId = taxIdTrim; + } + if (Object.keys(personPatch).length > 0) { + const { error } = await apiUsersIdpersonPatch({ + path: { id }, + headers, + body: personPatch as any, + }); + personError = error; + } + } else { + personError = null; + } + + if (personError) { + throw personError; + } + + // The names feed the display name, refresh the cookie so the header stays in sync + const [{ data: user }, { data: person }] = await Promise.all([ + apiUsersIdOrHandleGet({ path: { idOrHandle: id }, headers }), + apiUsersIdpersonGet({ path: { id }, headers }), + ]); + + if (user) { + setSession(context.cookies, { ...session, user }); + } + + return { user: user ?? session.user, person }; + } catch (error) { + console.error("Personal profile update error:", error); + + const detail = + typeof error === "object" && error !== null && "detail" in error + ? (error as { detail?: string | null }).detail + : undefined; + + throw new ActionError({ + code: "BAD_REQUEST", + message: detail || t("pages.me.manage.personal.error"), + }); + } + }, +}); diff --git a/src/actions/projectReview.ts b/src/actions/projectReview.ts new file mode 100644 index 00000000..75e7a15b --- /dev/null +++ b/src/actions/projectReview.ts @@ -0,0 +1,174 @@ +import { ActionError, defineAction } from "astro:actions"; +import { z } from "zod"; + +import { apiProjectsIdPatch } from "../openapi/client"; +import { createReviewAreaComment, getReview, setReviewAreaRisk } from "../services/projectReview"; +import { canAccessReview, isConsultant, sessionUserIri } from "../services/reviewAccess"; +import { PROJECT_REVIEW_STATUSES } from "../types/projectReview"; +import { extractId } from "../utils/extractId"; + +/** + * Every action below posts to /_actions/*, which no firewall rule matches, so the + * permissions of the reviews feature are enforced here rather than by the ACL. + */ + +const reviewId = z.coerce.number().int().positive(); +const areaId = z.coerce.number().int().positive(); + +/** + * Assesses the risk of a reviewable area. + * + * The client sends which area to change, never the review as a whole: a caller + * cannot use this to reach a review it is not the consultant of. The entry the + * change publishes in the conversation comes back with the result, so the chat + * already on screen shows it without waiting for a reload. + */ +export const updateReviewAreaRisk = defineAction({ + accept: "json", + input: z.object({ + reviewId, + areaId, + risk: z.enum(["low", "medium", "high"]), + }), + handler: async (input, context) => { + const { session, t } = context.locals; + + if (!isConsultant(session)) { + throw new ActionError({ + code: "FORBIDDEN", + message: t("pages.review.errors.forbidden"), + }); + } + + const { data, error } = setReviewAreaRisk( + input.reviewId, + input.areaId, + input.risk, + sessionUserIri(session!), + ); + + if (error || !data) { + throw new ActionError({ + code: "NOT_FOUND", + message: t("pages.review.errors.areaNotFound"), + }); + } + + // The chat renders `entry` right away: the page it is showing was rendered + // before this change, so the entry only reaches it through this response. + return { area: data.area, entry: data.entry }; + }, +}); + +/** + * Moves the reviewed project to another status of its review. + * + * The project is resolved from the review instead of being taken from the input, + * so the status of a project cannot be changed through a review that is not its + * own, and the target status is restricted to the review statuses. + */ +export const updateReviewProjectStatus = defineAction({ + accept: "json", + input: z.object({ + reviewId, + status: z.enum(PROJECT_REVIEW_STATUSES), + }), + handler: async (input, context) => { + const { session, t } = context.locals; + + if (!isConsultant(session)) { + throw new ActionError({ + code: "FORBIDDEN", + message: t("pages.review.errors.forbidden"), + }); + } + + const { data: review } = getReview(input.reviewId); + + if (!review) { + throw new ActionError({ + code: "NOT_FOUND", + message: t("pages.review.errors.notFound"), + }); + } + + const projectId = extractId(review.project); + + if (!projectId) { + throw new ActionError({ + code: "NOT_FOUND", + message: t("pages.review.errors.notFound"), + }); + } + + const { error } = await apiProjectsIdPatch({ + path: { id: projectId }, + headers: { + ...session!.token.asHttpHeaders, + "Content-Language": context.locals.lang, + }, + body: { id: Number(projectId), status: input.status }, + }); + + if (error) { + console.error({ error }); + + throw new ActionError({ + code: "INTERNAL_SERVER_ERROR", + message: t("pages.review.errors.statusNotUpdated"), + }); + } + + return { status: input.status }; + }, +}); + +/** + * Sends a message in the conversation of a reviewable area. + * + * Both the consultant and the promoter of the project take part, so this is the + * one action open to both roles. + */ +export const sendReviewAreaComment = defineAction({ + accept: "json", + input: z.object({ + reviewId, + areaId, + body: z.string("system.constraint.text.notEmpty").trim().min(1).max(2000), + }), + handler: async (input, context) => { + const { session, t } = context.locals; + + const { data: review } = getReview(input.reviewId); + + if (!review) { + throw new ActionError({ + code: "NOT_FOUND", + message: t("pages.review.errors.notFound"), + }); + } + + if (!(await canAccessReview(session, review))) { + throw new ActionError({ + code: "FORBIDDEN", + message: t("pages.review.errors.forbidden"), + }); + } + + const { data: comment, error } = createReviewAreaComment( + input.reviewId, + input.areaId, + sessionUserIri(session!), + input.body, + ); + + if (error || !comment) { + throw new ActionError({ + code: "NOT_FOUND", + message: t("pages.review.errors.areaNotFound"), + }); + } + + return { comment }; + }, +}); diff --git a/src/actions/publicProfile.ts b/src/actions/publicProfile.ts new file mode 100644 index 00000000..d371442b --- /dev/null +++ b/src/actions/publicProfile.ts @@ -0,0 +1,76 @@ +import { ActionError, defineAction } from "astro:actions"; + +import { setSession } from "../auth/session.ts"; +import { apiUsersIdOrHandleGet, apiUsersIdPatch } from "../openapi/client/index.ts"; +import { getSocialNetwork, SOCIAL_NETWORKS, toSocialLinkUrl } from "../utils/socialLinks.ts"; +import { zProfileForm } from "../validation/publicProfileValidation.ts"; + +export const updatePublicProfile = defineAction({ + accept: "json", + input: zProfileForm, + handler: async (input, context) => { + const { t, session } = context.locals; + + if (!session) { + throw new ActionError({ + code: "UNAUTHORIZED", + message: t("system.error.unauthorized"), + }); + } + + const id = String(session.user.id); + const headers = session.token.asHttpHeaders; + + // PATCH replaces the whole list, keep the links the form does not manage (websites, etc.) + const currentLinks = (session.user.links ?? []).flatMap((link) => link.url ?? []); + const links = [ + ...currentLinks.filter((url) => !getSocialNetwork(url)), + ...SOCIAL_NETWORKS.flatMap((network) => { + const url = input.links[network].trim(); + + return (url && toSocialLinkUrl(network, url)) || []; + }), + ]; + + try { + // User first: switching to `organization` makes the API create the Organization record + const { error: userError } = await apiUsersIdPatch({ + path: { id }, + headers, + body: { + avatar: input.avatar, + description: input.description.trim(), + links, + }, + }); + + if (userError) { + throw userError; + } + + // Keep the session cookie in sync so header and profile pages show fresh data + const { data: user } = await apiUsersIdOrHandleGet({ + path: { idOrHandle: id }, + headers, + }); + + if (user) { + setSession(context.cookies, { ...session, user }); + } + + return { user: user ?? session.user }; + } catch (error) { + console.error("User profile update error:", error); + + const detail = + typeof error === "object" && error !== null && "detail" in error + ? (error as { detail?: string | null }).detail + : undefined; + + throw new ActionError({ + code: "BAD_REQUEST", + message: detail || t("pages.me.manage.error"), + }); + } + }, +}); diff --git a/src/auth/session.ts b/src/auth/session.ts index ca0a568e..31f61bf1 100644 --- a/src/auth/session.ts +++ b/src/auth/session.ts @@ -40,6 +40,40 @@ export async function getSession(cookies: AstroCookies): Promise { + const session = await getSession(cookies); + + if (!session) return undefined; + + try { + const { data: user } = await apiUsersIdOrHandleGet({ + path: { idOrHandle: decodeJWT(session.token.access_token).sub }, + headers: session.token.asHttpHeaders, + }); + + if (!user) return undefined; + + const fresh = { ...session, user }; + + if (JSON.stringify(user.roles) !== JSON.stringify(session.user.roles)) { + setSession(cookies, fresh); + } + + return fresh; + } catch (err) { + console.error(err); + + return undefined; + } +} + /** * Store the session data into a secure, http-only cookie * @param cookies AstroCookies interface diff --git a/src/components/admin/ChatTextarea.svelte b/src/components/admin/ChatTextarea.svelte deleted file mode 100644 index 1fa8d25c..00000000 --- a/src/components/admin/ChatTextarea.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - -
- - -
diff --git a/src/components/admin/ExportCsv.svelte b/src/components/admin/ExportCsv.svelte index f2629efb..fc38fa8f 100644 --- a/src/components/admin/ExportCsv.svelte +++ b/src/components/admin/ExportCsv.svelte @@ -13,13 +13,19 @@ queryParams = {}, filenamePrefix = "export", totalItems = undefined, + size = "sm", + disabled = false, } = $props<{ endpoint?: string; queryParams?: Record; filenamePrefix?: string; totalItems?: number; + size?: "sm" | "md"; + disabled?: boolean; }>(); + const iconSize = $derived(size === "md" ? "24" : "16"); + let abortController = $state(null); let isExporting = $state(false); let exportProgress = $state(0); @@ -150,21 +156,22 @@
- +
@@ -186,7 +208,7 @@
{#each row as span}
@@ -216,7 +238,14 @@ number: String(index + 1).padStart(2, "0"), })}

- {#if slot.projectId} + {#if slot} + {#if slot.projectImage} + {slot.projectTitle} + {/if}

{slot.projectTitle}

@@ -250,12 +279,14 @@
+{errorMessage} + {#snippet header()} diff --git a/src/components/admin/home/hero/Hero.svelte b/src/components/admin/home/hero/Hero.svelte index 7cbc62e4..168ac6d6 100644 --- a/src/components/admin/home/hero/Hero.svelte +++ b/src/components/admin/home/hero/Hero.svelte @@ -1,4 +1,6 @@ @@ -35,20 +60,20 @@

-{errorMessage} +{errorMessage}
{#if currentSubtab === "fields"} - + {:else if currentSubtab === "history"} (list = list.filter((h) => h.id !== id))} - onError={(message) => { + rows={heroesList} + onError={(message: string) => { errorMessage = message; showError = true; }} + onSaved={refreshHeroes} /> {/if}
diff --git a/src/components/admin/home/hero/HeroForm.svelte b/src/components/admin/home/hero/HeroForm.svelte index 99851df2..5606e02c 100644 --- a/src/components/admin/home/hero/HeroForm.svelte +++ b/src/components/admin/home/hero/HeroForm.svelte @@ -1,27 +1,95 @@ +{#snippet textFields(fields: HeroFields)} +
+ (fields.title = String(value))} + required + placeholder={$t("pages.admin.home.hero.fields.titlePlaceholder")} + error={fieldErrors.title && $t(fieldErrors.title)} + /> + + (fields.content = value)} + format="markdown" + showAlignment={false} + placeholder={$t("pages.admin.home.hero.fields.contentPlaceholder")} + error={fieldErrors.content && $t(fieldErrors.content)} + /> + +
+
+ (fields.primaryCtaText = String(value))} + placeholder={$t("pages.admin.home.hero.fields.primaryCtaPlaceholder")} + error={fieldErrors.primaryCtaText && $t(fieldErrors.primaryCtaText)} + /> +
+ +
+ (fields.primaryCtaLink = String(value))} + placeholder={$t("pages.admin.home.hero.fields.urlPlaceholder")} + error={fieldErrors.primaryCtaLink && $t(fieldErrors.primaryCtaLink)} + /> +
+
+ +
+
+ (fields.secondaryCtaText = String(value))} + placeholder={$t("pages.admin.home.hero.fields.secondaryCtaPlaceholder")} + error={fieldErrors.secondaryCtaText && $t(fieldErrors.secondaryCtaText)} + /> +
+ +
+ (fields.secondaryCtaLink = String(value))} + placeholder={$t("pages.admin.home.hero.fields.urlPlaceholder")} + error={fieldErrors.secondaryCtaLink && $t(fieldErrors.secondaryCtaLink)} + /> +
+
+
+{/snippet} +
+
+
+ (selectedLanguage = lang)} + /> + + + + {#if languages.length > 1} + + {/if} +
+
+
{$t("pages.admin.home.hero.fields.textsTitle")} -
- - - (content = value)} - format="markdown" - showFontSize={false} - showAlignment={false} - showImage={false} - placeholder={$t("pages.admin.home.hero.fields.contentPlaceholder")} - error={fieldErrors.content && $t(fieldErrors.content)} - /> - - -
-
- -
- -
- -
-
- -
-
- -
- -
- -
-
-
+ {#key selectedLanguage} + {@render textFields(fieldValues[selectedLanguage] ?? fieldValues[primaryLanguage])} + {/key}
@@ -302,7 +618,61 @@ -{errorMessage} + + {#snippet header()} + + {$t("pages.admin.home.hero.addTranslationModal.title")} + + {/snippet} + +
+

+ {$t("pages.admin.home.hero.addTranslationModal.description")} +

+ + refreshLanguageOptions(query)} + onChange={handleAddLanguageChange} + searchPlaceholder={$t("pages.admin.home.hero.addTranslationModal.searchPlaceholder")} + itemClass="text-start" + /> +
+ + {#snippet footer()} + + + {/snippet} +
+ + + +{errorMessage} import { actions } from "astro:actions"; - import { TableBodyCell } from "flowbite-svelte"; + import { Modal, TableBodyCell } from "flowbite-svelte"; import { tick } from "svelte"; - import HeroPreviewModal from "./HeroPreviewModal.svelte"; import { locale, t } from "../../../../i18n/store"; import { formatDate } from "../../../../utils/dates"; - import Eye from "../../../icons/media/Eye.svelte"; + import { getLanguageDisplayName } from "../../../../utils/lang"; + import HeroForm from "./HeroForm.svelte"; + import HeroPreviewModal from "./HeroPreviewModal.svelte"; + import Edit from "../../../icons/actions/Edit.svelte"; import Close from "../../../icons/navigation/Close.svelte"; + import Eye from "../../../icons/media/Eye.svelte"; import DeleteModal from "../../../library/feedback/DeleteModal.svelte"; + import Search from "../../../library/inputs/Search.svelte"; import ToggleSwitch from "../../../library/inputs/ToggleSwitch.svelte"; import DataTable from "../../../library/tables/DataTable.svelte"; - import Tag from "../../../library/tags/Tag.svelte"; import Title from "../../../library/typography/Title.svelte"; import type { HomeHeroRecord } from "../../../../repositories/homeHero"; @@ -20,10 +23,18 @@ interface Props { rows: HomeHeroRecord[]; onError?: (message: string) => void; - onDelete?: (id: number) => void; + onSaved?: () => void; } - let { rows, onError, onDelete }: Props = $props(); + let { rows, onError, onSaved }: Props = $props(); + + let list = $state(rows); + + // rows is refreshed after a save; keep the local list in sync, while the + // local deletions below continue to apply on top of the incoming list. + $effect(() => { + list = rows; + }); type Filter = "all" | "upcoming"; @@ -31,7 +42,7 @@ // Same rule as the repository's getActive(): rows come newest first, so // the first one already started is the one the home shows. - const activeId = $derived(rows.find((h) => h.startsAt.getTime() <= Date.now())?.id); + const activeId = $derived(list.find((h) => h.startsAt.getTime() <= Date.now())?.id); type Status = "active" | "upcoming" | "past"; @@ -41,14 +52,42 @@ return row.startsAt.getTime() > Date.now() ? "upcoming" : "past"; } - const filtered = $derived(filter === "all" ? rows : rows.filter((h) => statusOf(h) !== "past")); + const STATUS_BADGES: Record = { + active: "bg-green-100 text-green-800", + upcoming: "bg-amber-100 text-amber-800", + past: "bg-gray-100 text-gray-500", + }; + + let searchQuery = $state(""); + + const filtered = $derived( + list.filter((h) => { + if (filter === "upcoming" && statusOf(h) === "past") { + return false; + } + + const query = searchQuery.trim().toLowerCase(); + + if (!query) { + return true; + } + + return ( + String(h.id).includes(query) || + h.title.toLowerCase().includes(query) || + h.content.toLowerCase().includes(query) + ); + }), + ); const headers: DataTableHeader[] = [ + { key: "pages.admin.home.hero.history.headers.id", sortable: false, class: "w-16" }, { key: "pages.admin.home.hero.history.headers.title", sortable: false }, { key: "pages.admin.home.hero.history.headers.content", sortable: false }, { key: "pages.admin.home.hero.history.headers.startsAt", sortable: false }, + { key: "pages.admin.home.hero.history.headers.languages", sortable: false }, { key: "pages.admin.home.hero.history.headers.status", sortable: false }, - { key: "", sortable: false, class: "w-24" }, + { key: "", sortable: false, class: "w-36" }, ]; const itemsPerPage = 10; @@ -62,12 +101,8 @@ currentPage = 1; } - let isPreviewOpen = $state(false); - let heroToPreview = $state(null); - - function openPreview(row: HomeHeroRecord) { - heroToPreview = row; - isPreviewOpen = true; + function handleSearch() { + currentPage = 1; } let isDeleteModalOpen = $state(false); @@ -96,7 +131,7 @@ return; } - onDelete?.(hero.id); + list = list.filter((h) => h.id !== hero.id); // Let the parent's updated rows reach `filtered` before clamping. await tick(); @@ -106,6 +141,27 @@ currentPage -= 1; } } + + let isPreviewOpen = $state(false); + let previewRow = $state(null); + + function openPreview(row: HomeHeroRecord) { + previewRow = row; + isPreviewOpen = true; + } + + let isEditModalOpen = $state(false); + let rowToEdit = $state(null); + + function openEditModal(row: HomeHeroRecord) { + rowToEdit = row; + isEditModalOpen = true; + } + + function closeEditModal() { + rowToEdit = null; + isEditModalOpen = false; + }
@@ -115,16 +171,25 @@

{$t("pages.admin.home.hero.history.description")}

- +
+ + + +
(currentPage = page)} - onRowClick={openPreview} + onRowClick={openEditModal} > {#snippet children(row)} - + + {row.id} + + {row.title} {row.content} - {formatDate(row.startsAt, $locale)} + {row.startsAt.getTime() === 0 ? "—" : formatDate(row.startsAt, $locale)} + + + {(row.languages ?? []) + .map((code: string) => getLanguageDisplayName(code) ?? code) + .join(", ")} {@const status = statusOf(row)} - + {$t(`pages.admin.home.hero.history.status.${status}`)} - + - -
+ +
+ + + + + + +
+ {/snippet} + {#snippet footer()} +
+

+ + {$t("pages.admin.reviews.projects.table.rows.details.lastModified")} + + {date(project.dateUpdated)} +

+ +
+ {/snippet} + + {/snippet} + diff --git a/src/components/cart/ClearCart.svelte b/src/components/cart/ClearCart.svelte deleted file mode 100644 index bd05f719..00000000 --- a/src/components/cart/ClearCart.svelte +++ /dev/null @@ -1,9 +0,0 @@ - diff --git a/src/components/checkout/RewardClaims.svelte b/src/components/checkout/RewardClaims.svelte new file mode 100644 index 00000000..6188c477 --- /dev/null +++ b/src/components/checkout/RewardClaims.svelte @@ -0,0 +1,77 @@ + diff --git a/src/components/footer/FooterLink.svelte b/src/components/footer/FooterLink.svelte index 7da823a5..37f3697d 100644 --- a/src/components/footer/FooterLink.svelte +++ b/src/components/footer/FooterLink.svelte @@ -15,7 +15,7 @@ diff --git a/src/components/header/HeaderButtons.svelte b/src/components/header/HeaderButtons.svelte index 663c2894..2d313b82 100644 --- a/src/components/header/HeaderButtons.svelte +++ b/src/components/header/HeaderButtons.svelte @@ -13,11 +13,10 @@
- -
diff --git a/src/components/header/LanguagesDropdown.svelte b/src/components/header/LanguagesDropdown.svelte index 4507eed4..e71ac7ac 100644 --- a/src/components/header/LanguagesDropdown.svelte +++ b/src/components/header/LanguagesDropdown.svelte @@ -23,8 +23,6 @@ function selectLanguage(code: string) { open = false; - selected = code; - onSelect?.(code); } function toggleDropdown() { diff --git a/src/components/hero/Hero.svelte b/src/components/hero/Hero.svelte index 6e2e7363..a333f7f3 100644 --- a/src/components/hero/Hero.svelte +++ b/src/components/hero/Hero.svelte @@ -16,7 +16,7 @@ {#if hero}
-
+

{hero.title}

@@ -39,7 +39,7 @@ {#if hero.primaryCtaText && hero.primaryCtaLink}
{hero.primaryCtaText} @@ -47,7 +47,7 @@ {#if hero.secondaryCtaText && hero.secondaryCtaLink} {hero.secondaryCtaText} diff --git a/src/components/home/Blog.astro b/src/components/home/Blog.astro index 9b3b3c0b..984918c5 100644 --- a/src/components/home/Blog.astro +++ b/src/components/home/Blog.astro @@ -27,18 +27,6 @@ const blogPost = { {blogPost.excerpt}

- - -
@@ -47,7 +35,7 @@ const blogPost = { aria-label="Technology and digital transformation" role="img" class="h-full w-full bg-cover bg-center" - style="background-image:url('https://images.unsplash.com/photo-1519389950473-47ba0277781c?ixlib=rb-4.0.3&auto=format&fit=crop&w=1200&q=80');" + style="background-image:url('/images/home/blog.png');" >
diff --git a/src/components/home/CampaignCard.stories.svelte b/src/components/home/CampaignCard.stories.svelte new file mode 100644 index 00000000..add50823 --- /dev/null +++ b/src/components/home/CampaignCard.stories.svelte @@ -0,0 +1,50 @@ + + + + + + + + + + + diff --git a/src/components/home/CampaignCard.svelte b/src/components/home/CampaignCard.svelte index 6800acb4..48268489 100644 --- a/src/components/home/CampaignCard.svelte +++ b/src/components/home/CampaignCard.svelte @@ -9,11 +9,13 @@ Converted from CampaignCard.astro to maintain exact functionality import Clock from "../../components/icons/Clock.svelte"; import { t } from "../../i18n/store"; import { apiAccountingsIdGet, type Money } from "../../openapi/client"; + import { getDaysRemaining } from "../../utils/campaign"; import { formatCurrency } from "../../utils/currencies"; import { extractId } from "../../utils/extractId"; import { gte } from "../../utils/money"; - import CampaignStatusBadge from "../home/CampaignStatusBadge.svelte"; + import { PUBLIC_STATUSES_TO_STATUSES } from "../../utils/projectStatus"; import Flames from "../icons/status/Flames.svelte"; + import Ok from "../icons/status/Ok.svelte"; import Button from "../library/buttons/Button.svelte"; import Tag from "../library/tags/Tag.svelte"; import Title from "../library/typography/Title.svelte"; @@ -68,158 +70,142 @@ Converted from CampaignCard.astro to maintain exact functionality } }); - // Define responsive classes based on size - // Large cards span 2 columns in lg+ (3-column grid), 2 columns in md (2-column grid), full width on mobile - const sizeClasses = $derived( - size === "large" ? "col-span-1 md:col-span-2 lg:col-span-2" : "col-span-1", - ); + // Large cards span 2 columns in md+, full width on mobile + const sizeClasses = $derived(size === "large" ? "col-span-1 md:col-span-2" : "col-span-1"); - const imageHeight = "h-53.75"; // More rectangular proportions matching design - - // Calculate funding status and remaining amount const hasReachedMinimum = $derived( obtained != null && campaign.minimum != null ? gte(obtained, campaign.minimum) : false, ); - // Determine status badge text based on funding level - // Using lookup pattern for consistency with other i18n implementations - const statusBadgeText = $derived.by(() => { - const key = hasReachedMinimum ? "minimumReached" : "goForMinimum"; - return $t(`pages.home.campaigns.status.${key}`); + const isFinished = $derived( + [...PUBLIC_STATUSES_TO_STATUSES.funded, ...PUBLIC_STATUSES_TO_STATUSES.archived].includes( + campaign.status ?? "", + ), + ); + const daysRemaining = $derived(getDaysRemaining(campaign.calendar)); + const hasMatchfunding = $derived( + campaign.hasMatchfunding ?? !!campaign.matchCallSubmissions?.length, + ); + + // Two-phase bar: red fill towards the minimum; once reached it resets to a full green first + // third plus a green fill towards the optimum (additive on top of the minimum). Overflow is + // clamped so it never shows. + const ratio = (part = 0, total = 0) => (total > 0 ? Math.min(Math.max(part / total, 0), 1) : 1); + const barFill = $derived.by(() => { + const raised = obtained?.amount ?? 0; + const minimum = campaign.minimum?.amount ?? 0; + return hasReachedMinimum + ? ratio(raised - minimum, campaign.optimum?.amount) + : ratio(raised, minimum); }); - // Get first category only (as per review comments) - const firstCategory = $derived(() => { - if (Array.isArray(campaign.category)) { - return campaign.category[0] || null; - } - return campaign.category || null; - }); + // Right-hand amount: the minimum until it's reached, then the optimum + const goal = $derived( + campaign.optimum && hasReachedMinimum + ? { key: "optimum", money: campaign.optimum } + : { key: "minimum", money: campaign.minimum }, + );
-
- -
- +
+
+ {campaign.title} +
- - {#if campaign.hasMatchfunding} - - + {#if ownedConfig?.tagLabel} + {ownedConfig.tagLabel} + {/if} + {#if isFinished} + + + {$t("pages.home.campaigns.finished")} + + {:else if daysRemaining !== undefined} + + + + {$t("pages.home.campaigns.daysRemaining", { days: daysRemaining })} + + + {/if} + {#if hasMatchfunding} + + {$t("pages.home.campaigns.matchfunding")} {/if} - - - {#if campaign.tags} - {#each campaign.tags as tag} - - {tag} - - {/each} - {/if} + {#each campaign.tags ?? [] as tag} + {tag} + {/each}
- - {#if campaign.status === "in_campaign"} -
- -
+ {#if hasReachedMinimum} + + {$t("pages.home.campaigns.minimumExceeded")} + {/if}
- -
- -
- - {#if ownedConfig?.tagLabel} - - {ownedConfig.tagLabel} - - {/if} - - {#if campaign.daysRemaining !== undefined} - - - - {$t("pages.home.campaigns.daysRemaining", { - days: campaign.daysRemaining, - })} - - - {/if} - - - {#if firstCategory()} - - - - {$t(`categories.${firstCategory()}`)} - - - {/if} -
- - +
{campaign.title} - {#if !ownedConfig || ownedConfig.showMoney !== false} -
- -
-
- {$t("pages.home.campaigns.obtained")} - - {#if obtained} - {formatCurrency(obtained)} - {:else} - {$t("system.loading")} - {/if} - + {#if obtained} +
+ {#if hasReachedMinimum} +
+ {/if} +
+
- -
- {#if campaign.optimum && hasReachedMinimum} - - {$t("pages.home.campaigns.optimum")} - - - {formatCurrency(campaign.optimum)} - +
+ {/if} + +
+
+ {$t("pages.home.campaigns.obtained")} + + {#if obtained} + {formatCurrency(obtained)} {:else} - - {$t("pages.home.campaigns.minimum")} - - - {formatCurrency(campaign.minimum)} - + {$t("system.loading")} {/if} -
+ +
+
+ {$t(`pages.home.campaigns.${goal.key}`)} + + {formatCurrency(goal.money)} +
{/if} diff --git a/src/components/home/CampaignStatusBadge.stories.svelte b/src/components/home/CampaignStatusBadge.stories.svelte deleted file mode 100644 index 440719f5..00000000 --- a/src/components/home/CampaignStatusBadge.stories.svelte +++ /dev/null @@ -1,16 +0,0 @@ - - - diff --git a/src/components/home/CampaignStatusBadge.svelte b/src/components/home/CampaignStatusBadge.svelte deleted file mode 100644 index 67bce5d9..00000000 --- a/src/components/home/CampaignStatusBadge.svelte +++ /dev/null @@ -1,19 +0,0 @@ - - -
- {text} -
diff --git a/src/components/home/Campaigns.astro b/src/components/home/Campaigns.astro index ef6522a2..448f34b8 100644 --- a/src/components/home/Campaigns.astro +++ b/src/components/home/Campaigns.astro @@ -61,7 +61,7 @@ const campaigns = await Promise.all( size: sizeMap[index] ?? "small", slug: project.slug!, title: project.title!, - image: project.video?.thumbnail! || project.cover!, + image: project.cover || project.video?.thumbnail || "", minimum: project.budget?.minimum?.money!, obtained: accounting!.balance as Money, optimum: project.budget?.optimum?.money, @@ -79,7 +79,7 @@ const campaigns = await Promise.all( -
diff --git a/src/components/library/buttons/Button.svelte b/src/components/library/buttons/Button.svelte index f6d79ca8..5ab8eb8b 100644 --- a/src/components/library/buttons/Button.svelte +++ b/src/components/library/buttons/Button.svelte @@ -5,8 +5,8 @@ import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements"; const sizeStyles = { - md: "px-8 py-4 rounded-3xl", - sm: "px-4 py-2 rounded-2xl", + md: "px-8 py-4 rounded-xl", + sm: "px-4 py-2 rounded-xl", }; const kindStyles = { @@ -40,7 +40,7 @@ const buttonClass = $derived( twMerge( - "text-secondary disabled:bg-grey flex w-auto items-center justify-center gap-2 font-bold transition hover:cursor-pointer", + "text-content disabled:bg-grey flex w-auto items-center justify-center gap-2 font-bold transition hover:cursor-pointer", sizeStyles[size], kindStyles[kind], href && "hover:underline", diff --git a/src/components/library/dropdown/DropdownItem.svelte b/src/components/library/dropdown/DropdownItem.svelte index 8b3d4d0d..4c161c19 100644 --- a/src/components/library/dropdown/DropdownItem.svelte +++ b/src/components/library/dropdown/DropdownItem.svelte @@ -1,5 +1,7 @@
{/if}
+{:else if noVideoSrc && previewImage} + {poster.alt} {:else if noVideoSrc}
- import { onMount } from "svelte"; - import DonationsCard from "./DonationsCard.svelte"; import MatchfundingCard from "./MatchfundingCard.svelte"; import ProjectsCard from "./ProjectsCard.svelte"; - import { client } from "../../openapi/client/client.gen.ts"; - import { apiUsersIdOrHandleGetUrl } from "../../openapi/client/operation-paths.gen.ts"; - import { - apiAccountingsIdGet, - apiProjectSupportsGetCollection, - apiProjectSupportsmoneyTotalGetCollection, - apiProjectsGetCollection, - apiProjectsIdOrSlugGet, - apiMatchCallsGetCollection, - } from "../../openapi/client/sdk.gen.ts"; - import { projectCache } from "../../stores/projectCache"; - import { getDefaultCurrency } from "../../utils/consts"; - import { extractId } from "../../utils/extractId"; - import { toCollectionItems } from "../../utils/hydra.ts"; - import { sumMoney } from "../../utils/money"; import Grid from "../library/layout/Grid.svelte"; - import type { - ProjectSupport, - Project, - MatchCall, - User, - MoneyOutput, - } from "../../openapi/client/types.gen.ts"; - import type { ActivityData, MatchfundingCardData } from "../../types/me-page"; + import type { User } from "../../openapi/client/types.gen.ts"; interface Props { - lang: string; period?: string; user: User; } - let { lang, period = new Date().getFullYear().toString(), user }: Props = $props(); + let { period = new Date().getFullYear().toString(), user }: Props = $props(); - let activityData = $state(undefined); - let matchfundingData = $state(undefined); - let loading = $state(true); - let error = $state(null); + let hasMatchfunding = $state(false); - async function fetchActivityData() { - loading = true; - error = null; - - try { - // Project details are cached in a session-level store (projectCache) - // This cache persists across component mounts and page navigations, - // reducing redundant API calls and improving performance. - // Projects are indexed by ID, slug, and original IRI segment. - - // Only send Accept-Language header; auth is handled by the relay - const headers = { - "Accept-Language": lang, - }; - - // Fetch user's contributions (donations) - using accounting IRI as origin - const { data: supportsResponse, error: supportsError } = - await apiProjectSupportsGetCollection({ - baseUrl: "/api/relay", - query: { - origin: user.accounting, - itemsPerPage: 100, - }, - headers, - }); - - // Try to fetch total money contributed, but it might fail with 500 - // In that case, we'll calculate it manually from the contributions - let totalMoney = null; - let totalMoneyError = null; - - try { - const response = await apiProjectSupportsmoneyTotalGetCollection({ - baseUrl: "/api/relay", - query: { - origin: user.accounting, - }, - headers, - }); - totalMoney = response.data; - totalMoneyError = response.error; - } catch (err) { - console.warn("money_total endpoint failed, will calculate manually:", err); - totalMoneyError = err; - } - - // Log detailed error information - if (totalMoneyError) { - console.warn("Total money endpoint returned error, calculating manually:", { - error: totalMoneyError, - user, - }); - } - - // Fetch user's owned projects - using user IRI as owner - const userIri = client.buildUrl({ - url: apiUsersIdOrHandleGetUrl, - path: { idOrHandle: user.id }, - }); - - const { data: projectsResponse, error: projectsError } = await apiProjectsGetCollection( - { - baseUrl: "/api/relay", - query: { - owner: userIri, - itemsPerPage: 3, - }, - headers, - }, - ); - - // Only fail if we can't get the critical data (contributions or projects) - if (supportsError) { - console.error("Failed to fetch contributions:", supportsError); - error = "Failed to load donation data"; - return; - } - - if (projectsError) { - console.error("Failed to fetch projects:", projectsError); - } - - // Transform the data into the ActivityData format expected by ActivityCard - const contributions = toCollectionItems(supportsResponse); - const projects = toCollectionItems(projectsResponse); - - const contributionsCount = Array.isArray(supportsResponse) - ? supportsResponse.length - : contributions.length; - const projectsCount = Array.isArray(projectsResponse) - ? projectsResponse.length - : projects.length; - - // Use session-level cache for project details (persists across component mounts) - // Add owned projects to the cache first - projectCache.addMany(projects); - - const supportProjectIdOrSlugs = Array.from( - new Set( - contributions - .map((support) => - extractId( - typeof support.project === "string" ? support.project : undefined, - ), - ) - .filter((idOrSlug): idOrSlug is string => Boolean(idOrSlug)), - ), - ); - - // Check which projects are not in the cache and need to be fetched - const slugsToFetch = supportProjectIdOrSlugs.filter( - (idOrSlug) => !projectCache.has(idOrSlug), - ); - - if (slugsToFetch.length > 0) { - const projectDetailResults = await Promise.all( - slugsToFetch.map(async (idOrSlug) => { - try { - const { data, error } = await apiProjectsIdOrSlugGet({ - baseUrl: "/api/relay", - path: { idOrSlug }, - headers, - }); - - if (error) { - console.warn("Failed to fetch project detail for support", { - idOrSlug, - error, - }); - return { idOrSlug, project: null }; - } - - return { idOrSlug, project: data ?? null }; - } catch (fetchError) { - console.warn("Error fetching project detail for support", { - idOrSlug, - fetchError, - }); - return { idOrSlug, project: null }; - } - }), - ); - - // Add fetched projects to the cache using multiple keys for reliable lookups - projectDetailResults.forEach(({ idOrSlug, project: projectDetail }) => { - if (!projectDetail) return; - - // Add to the persistent cache with all keys - projectCache.add(projectDetail, idOrSlug); - }); - } - - const accountingIds = Array.from( - new Set( - projects - .map((project) => extractId(project.accounting)) - .filter((id): id is string => Boolean(id)), - ), - ); - - let projectsTotalAmount = 0; - let projectsTotalCurrency: string | null = null; - - if (accountingIds.length > 0) { - const accountingResults = await Promise.all( - accountingIds.map(async (accountingId) => { - try { - const { data, error } = await apiAccountingsIdGet({ - baseUrl: "/api/relay", - path: { id: accountingId }, - headers, - }); - - if (error) { - console.warn("Failed to fetch accounting", { - accountingId, - error, - }); - return null; - } - - return data ?? null; - } catch (accountingError) { - console.warn("Error fetching accounting", { - accountingId, - accountingError, - }); - return null; - } - }), - ); - - const projectBalances = accountingResults - .map((a) => a?.balance) - .filter((b): b is MoneyOutput => b != null && typeof b.amount === "number"); - - if (projectBalances.length > 0) { - const total = sumMoney(projectBalances); - projectsTotalAmount = total.amount ?? 0; - projectsTotalCurrency = total.currency ?? getDefaultCurrency(); - } - } - - const fallbackNow = new Date().toISOString(); - - const resolveSupportDate = (support: ProjectSupport): string => { - const createdAtValue = (support as { createdAt?: unknown }).createdAt; - - if (typeof createdAtValue === "string") { - return createdAtValue; - } - - return fallbackNow; - }; - - // Calculate total money (either from API or manually) - const totalMoneyValue = totalMoney - ? { - amount: totalMoney.amount ?? 0, - currency: totalMoney.currency ?? getDefaultCurrency(), - } - : contributions.length > 0 - ? sumMoney( - contributions.map( - (s) => s.money ?? { amount: 0, currency: getDefaultCurrency() }, - ), - ) - : { amount: 0, currency: getDefaultCurrency() }; - - let totalAmount = totalMoneyValue.amount; - let totalCurrency = totalMoneyValue.currency; - - // Map contributions to recent donations - const sortedContributions = [...contributions].sort((a, b) => { - const dateA = Date.parse(resolveSupportDate(a)); - const dateB = Date.parse(resolveSupportDate(b)); - return dateB - dateA; - }); - - const recentDonations = sortedContributions.slice(0, 3).map((support) => { - const idOrSlug = extractId( - typeof support.project === "string" ? support.project : undefined, - ); - // Get project from the persistent cache - const project = idOrSlug ? projectCache.get(idOrSlug) : undefined; - - // Validate that we have a proper slug, not just an ID - const hasValidSlug = project?.slug && isNaN(Number(project.slug)); - - // Ensure we always use the slug for URLs, never the ID - // If project is not found or slug is invalid, log an error - if (!project && idOrSlug) { - const cacheStats = projectCache.getStats(); - console.error("Project not found in cache", { - idOrSlug, - supportId: support.id, - cacheSize: cacheStats.size, - sampleKeys: cacheStats.sampleKeys, - }); - } else if (project && !hasValidSlug) { - console.error("Project found but has invalid slug (numeric or missing)", { - projectId: project.id, - projectSlug: project.slug, - supportId: support.id, - }); - } - - return { - id: support.id?.toString() || idOrSlug || "", - amount: support.money || { amount: 0, currency: getDefaultCurrency() }, - // Use project title if available, otherwise use a placeholder - projectTitle: project?.title || `Project ${idOrSlug || "Unknown"}`, - // CRITICAL: Only use slug if it's valid (not a number). Never fall back to ID. - // Empty string will prevent broken links - UI should handle gracefully - projectSlug: hasValidSlug ? project.slug : "", - date: resolveSupportDate(support), - }; - }); - - // Map projects to recent projects - const recentProjects = projects.slice(0, 3).map((project) => { - // Validate that slug is not numeric (should never happen for owned projects, but be defensive) - const hasValidSlug = project.slug && isNaN(Number(project.slug)); - - if (!hasValidSlug) { - console.error("Owned project has invalid or missing slug", { - projectId: project.id, - projectSlug: project.slug, - projectTitle: project.title, - }); - } - - return { - id: project.id?.toString() || "", - title: project.title || "", - // Only use slug if it's valid (not numeric or empty) - slug: hasValidSlug ? project.slug : "", - status: (project.status as any) || "in_draft", - createdAt: project.dateCreated || new Date().toISOString(), - }; - }); - - activityData = { - donations: { - count: contributionsCount, - total: { - amount: totalAmount, - currency: totalCurrency, - }, - recentDonations, - }, - projects: projectsError - ? undefined - : { - count: projectsCount, - totalRaised: { - amount: projectsTotalAmount, - currency: projectsTotalCurrency ?? getDefaultCurrency(), - }, - recentProjects, - }, - }; - - // Fetch matchfunding data - try { - const { data: callsData, error: callsError } = await apiMatchCallsGetCollection({ - baseUrl: "/api/relay", - query: { - itemsPerPage: 100, - }, - headers, - }); - - if (!callsError && callsData) { - const calls = toCollectionItems(callsData); - - // Filter calls where user is a manager - const userCalls = calls.filter((call) => { - if (!call.managers || call.managers.length === 0) return false; - // managers is Array containing IRI paths like "/v4/users/123" - return call.managers.some((managerIri) => - managerIri.includes(String(user.id)), - ); - }); - - if (userCalls.length > 0) { - // Fetch accounting data for each call to get donation amounts - const callAccountings = await Promise.all( - userCalls.map(async (call) => { - const accountingId = extractId(call.accounting); - if (!accountingId) - return { - callId: call.id, - amount: 0, - currency: getDefaultCurrency(), - }; - - try { - const { data: accounting } = await apiAccountingsIdGet({ - baseUrl: "/api/relay", - path: { id: accountingId }, - headers, - }); - - return { - callId: call.id, - amount: accounting?.balance?.amount || 0, - currency: - accounting?.balance?.currency || getDefaultCurrency(), - }; - } catch { - return { - callId: call.id, - amount: 0, - currency: getDefaultCurrency(), - }; - } - }), - ); - - // Calculate total donated across all calls - const totalDonatedMoney = sumMoney( - callAccountings.map((acc) => ({ - amount: acc.amount, - currency: acc.currency, - })), - ); - const totalDonated = totalDonatedMoney.amount ?? 0; - const currency = totalDonatedMoney.currency ?? getDefaultCurrency(); - - // Get recent calls (up to 3) - const recentCalls = userCalls.slice(0, 3).map((call, index) => ({ - id: call.id || 0, - title: call.title || "", - donationAmount: { - amount: callAccountings[index]?.amount || 0, - currency: callAccountings[index]?.currency || getDefaultCurrency(), - }, - })); - - matchfundingData = { - totalCalls: userCalls.length, - totalDonated: { - amount: totalDonated, - currency, - }, - recentCalls, - }; - } - } - } catch (matchfundingError) { - console.warn("Failed to fetch matchfunding data:", matchfundingError); - // Don't fail the whole page if matchfunding fails - matchfundingData = undefined; - } - } catch (err) { - console.error("Error fetching activity data:", err); - error = "Failed to load activity data"; - } finally { - loading = false; - } + function onMatchfundingDataChange(hasData: boolean) { + hasMatchfunding = hasData; } - - onMount(() => { - fetchActivityData(); - }); - - // Re-fetch when period changes - $effect(() => { - if (period) { - fetchActivityData(); - } - }); - - {#if loading} - -
-

Loading...

-
-
-

Loading...

-
-
-

Loading...

-
- {:else if error} - -
-

{error}

-
-
-

{error}

-
-
-

{error}

-
- {:else} - - - - {#if matchfundingData} - - {/if} - {/if} + + + +
+ +
diff --git a/src/components/profile/DonatedProjectsSection.svelte b/src/components/profile/DonatedProjectsSection.svelte index 2750e799..fca4193f 100644 --- a/src/components/profile/DonatedProjectsSection.svelte +++ b/src/components/profile/DonatedProjectsSection.svelte @@ -134,14 +134,6 @@ // Only show projects that are in campaign if (project.status !== "in_campaign") return null; - // Calculate days remaining - let daysRemaining: number | undefined; - if (project.calendar?.minimum) { - const endDate = new Date(project.calendar.minimum); - const today = new Date(); - const diffTime = endDate.getTime() - today.getTime(); - daysRemaining = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - } return { ...project, slug: project.slug!, @@ -151,7 +143,6 @@ optimum: project.budget?.optimum?.money, obtained: accounting.balance as Money, category: project.categories?.[0], // Get first category - daysRemaining, userDonations: projectDonations.get(accountingIRI) ?? { amount: 0, currency: getDefaultCurrency(), diff --git a/src/components/profile/DonationsCard.svelte b/src/components/profile/DonationsCard.svelte index 0e9b15fc..25b7bcba 100644 --- a/src/components/profile/DonationsCard.svelte +++ b/src/components/profile/DonationsCard.svelte @@ -1,80 +1,232 @@ +{#snippet loadingShell()} +
+
+ +

{$t("system.loading")}

+
+
+{/snippet} + +{#snippet errorShell(message: unknown)} +
+

{message}

+
+{/snippet} + - - {#if donationsData?.recentDonations} - {#each donationsData.recentDonations.slice(0, 2) as donation} -
  • - - {formatAmountWithSymbol(donation.amount.amount, donation.amount.currency, lang)} - - - - {#if donation.projectSlug} - - {donation.projectTitle || fallbackProjectTitle} - - {:else} - - {donation.projectTitle || fallbackProjectTitle} - - {/if} -
  • - {/each} - {/if} - +{#if donations} + {#await donations} + {@render loadingShell()} + {:then summary} + + {#if summary?.recentDonations} + {#each summary.recentDonations.slice(0, 2) as donation} +
  • + + {formatAmountWithSymbol( + donation.amount.amount, + donation.amount.currency, + $locale, + )} + + - + {#if donation.projectSlug} + + {donation.projectTitle} + + {:else} + + {donation.projectTitle} + + {/if} +
  • + {/each} + {/if} +
    + {:catch donationsError} + {@render errorShell(donationsError)} + {/await} +{:else} + {@render loadingShell()} +{/if} diff --git a/src/components/profile/ManagePreferencesSection.svelte b/src/components/profile/ManagePreferencesSection.svelte new file mode 100644 index 00000000..ebb964d5 --- /dev/null +++ b/src/components/profile/ManagePreferencesSection.svelte @@ -0,0 +1,107 @@ + + + +
    +
    +

    + {$t("pages.me.manage.preferences.title")} +

    +

    + {$t("pages.me.manage.preferences.subtitle")} +

    +
    + + + {$t("pages.me.manage.preferences.save")} + +
    + +
    + + {$t("pages.me.manage.preferences.languages.title")} + + +
    +
    + +
    +
    +
    + + + + + {$t("pages.me.manage.preferences.toast.saved")} + diff --git a/src/components/profile/MatchfundingCard.svelte b/src/components/profile/MatchfundingCard.svelte index 87675251..6876272e 100644 --- a/src/components/profile/MatchfundingCard.svelte +++ b/src/components/profile/MatchfundingCard.svelte @@ -1,61 +1,229 @@ -{#if hasData} - - {#if data?.recentCalls} - {#each data.recentCalls.slice(0, 2) as call} -
  • - - {formatAmountWithSymbol( - call.donationAmount.amount, - call.donationAmount.currency, - lang, - )} - - - - - {call.title} - -
  • - {/each} - {/if} -
    +{#if data} + {#await data} + +
    +
    + +

    {$t("system.loading")}

    +
    +
    + {:then summary} + + {#if summary?.recentCalls} + {#each summary.recentCalls.slice(0, 2) as call} +
  • + + {formatAmountWithSymbol( + call.donationAmount.amount, + call.donationAmount.currency, + $locale, + )} + + - + + {call.title} + +
  • + {/each} + {/if} +
    + {:catch matchfundingError} +
    +

    {matchfundingError.message}

    +
    + {/await} +{:else} + +
    +
    + +

    {$t("system.loading")}

    +
    +
    {/if} diff --git a/src/components/profile/MeNav.svelte b/src/components/profile/MeNav.svelte new file mode 100644 index 00000000..e70b0d44 --- /dev/null +++ b/src/components/profile/MeNav.svelte @@ -0,0 +1,43 @@ + + + diff --git a/src/components/profile/OwnedProjectsSection.svelte b/src/components/profile/OwnedProjectsSection.svelte index 7fd5d362..14216f37 100644 --- a/src/components/profile/OwnedProjectsSection.svelte +++ b/src/components/profile/OwnedProjectsSection.svelte @@ -57,15 +57,6 @@ // Transform projects to Campaign format const campaigns = (await Promise.all( projectItems.map(async (project) => { - // Calculate days remaining - let daysRemaining: number | undefined; - if (project.calendar?.minimum) { - const endDate = new Date(project.calendar.minimum); - const today = new Date(); - const diffTime = endDate.getTime() - today.getTime(); - daysRemaining = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - } - return { ...project, slug: project.slug!, @@ -78,7 +69,6 @@ minimum: project.budget?.minimum?.money, optimum: project.budget?.optimum?.money, category: project.categories?.[0], // Get first category - daysRemaining, } satisfies Campaign; }), )) as Campaign[]; diff --git a/src/components/profile/ProfileDonorType.svelte b/src/components/profile/ProfileDonorType.svelte index f807039a..c3efd30d 100644 --- a/src/components/profile/ProfileDonorType.svelte +++ b/src/components/profile/ProfileDonorType.svelte @@ -11,8 +11,8 @@ } let { - projectsDonated = 1, - moneyDonatedAmount = 500, + projectsDonated = 0, + moneyDonatedAmount = 0, moneyDonatedCurrency = getDefaultCurrency(), }: Props = $props(); @@ -32,7 +32,7 @@ {$t("pages.profile.donorType.fan.title")}

    - {$t("pages.profile.donorType.fan.description")} + {$t("pages.profile.donorType.fan.description", { amount: formattedMoney })}

    diff --git a/src/components/profile/ProfileInfo.svelte b/src/components/profile/ProfileInfo.svelte index 0aa06f4e..0efac715 100644 --- a/src/components/profile/ProfileInfo.svelte +++ b/src/components/profile/ProfileInfo.svelte @@ -1,10 +1,10 @@ - - {#if projectsData?.recentProjects} - {#each projectsData.recentProjects.slice(0, 2) as project} -
  • - {#if project.slug} - - {project.title} - - {:else} - - {project.title} - - {/if} -
  • - {/each} - {/if} -
    +{#if projects} + {#await projects} + +
    +
    + +

    {$t("system.loading")}

    +
    +
    + {:then summary} + + {#if summary?.recentProjects} + {#each summary.recentProjects.slice(0, 2) as project} +
  • + {#if project.slug} + + {project.title} + + {:else} + + {project.title} + + {/if} +
  • + {/each} + {/if} +
    + {:catch projectsError} +
    +

    {projectsError.message}

    +
    + {/await} +{:else} + +
    +
    + +

    {$t("system.loading")}

    +
    +
    +{/if} diff --git a/src/components/profile/User.svelte b/src/components/profile/User.svelte index b202730b..69ce0c63 100644 --- a/src/components/profile/User.svelte +++ b/src/components/profile/User.svelte @@ -16,7 +16,8 @@ interface Props { user: User; - campaigns?: Campaign[]; + ownedCampaigns?: Campaign[]; + donatedCampaigns?: Campaign[]; projectsDonated?: number; moneyDonatedAmount?: number; moneyDonatedCurrency?: string; @@ -24,7 +25,8 @@ let { user, - campaigns = [], + ownedCampaigns = [], + donatedCampaigns = [], projectsDonated = 0, moneyDonatedAmount = 0, moneyDonatedCurrency = getDefaultCurrency(), @@ -44,6 +46,18 @@ ]); +{#snippet campaignGrid(list: Campaign[], emptyKey: string)} + {#if list.length > 0} + + {#each list as campaign} + + {/each} + + {:else} +

    {$t(emptyKey)}

    + {/if} +{/snippet} +
    @@ -110,19 +124,15 @@ class="mx-auto mt-10 min-h-10 w-full max-w-5xl px-4" style="display:none" > - {#if campaigns.length > 0} - - {#each campaigns as campaign} - - {/each} - - {:else} -

    {$t("pages.profile.noProjects")}

    - {/if} + {@render campaignGrid(ownedCampaigns, "pages.profile.noProjects")}
    diff --git a/src/components/profile/manage/FormNotice.svelte b/src/components/profile/manage/FormNotice.svelte new file mode 100644 index 00000000..c7a8d0c9 --- /dev/null +++ b/src/components/profile/manage/FormNotice.svelte @@ -0,0 +1,20 @@ + + + +
    + +

    {text}

    +
    diff --git a/src/components/profile/manage/ManagePersonalProfile.svelte b/src/components/profile/manage/ManagePersonalProfile.svelte new file mode 100644 index 00000000..1077610e --- /dev/null +++ b/src/components/profile/manage/ManagePersonalProfile.svelte @@ -0,0 +1,326 @@ + + + +{#snippet cardHeader(title: string, subtitle: string)} +
    + {title} +

    {subtitle}

    +
    +{/snippet} + + + +
    + + {@render cardHeader($t("pages.me.manage.info.title"), $t("pages.me.manage.info.subtitle"))} + +
    + validate("handle")} + disabled={isSubmitting} + required + /> + {#if form.type === "individual"} +
    + + +
    + {:else} +
    + validate("legalName")} + disabled={isSubmitting} + required + /> + +
    + {/if} + + +
    + + {$t("pages.me.manage.info.locality")} + + +
    +
    + +
    + +
    + +
    + + + validate("taxId")} + disabled={isSubmitting} + required={form.type === "organization"} + /> + + {#if form.type === "organization"} +

    + {$t("pages.me.manage.info.representative")} +

    + + + {/if} +
    + + +
    + + {#if showSuccess} + + {$t("pages.me.manage.personal.success")} + + {/if} + {#if showError} + + {formError} + + {/if} + +
    + +
    +
    diff --git a/src/components/profile/manage/ManagePublicProfile.svelte b/src/components/profile/manage/ManagePublicProfile.svelte new file mode 100644 index 00000000..ca1bab86 --- /dev/null +++ b/src/components/profile/manage/ManagePublicProfile.svelte @@ -0,0 +1,157 @@ + + + +{#snippet cardHeader(title: string, subtitle: string)} +
    + {title} +

    {subtitle}

    +
    +{/snippet} + + + +
    + + + validate("links")} + disabled={isSubmitting} + /> + + + {$t("pages.me.manage.success")} + + + {formError} + + +
    + +
    + diff --git a/src/components/profile/manage/MotivatingProjectsSection.svelte b/src/components/profile/manage/MotivatingProjectsSection.svelte new file mode 100644 index 00000000..04d81fb4 --- /dev/null +++ b/src/components/profile/manage/MotivatingProjectsSection.svelte @@ -0,0 +1,38 @@ + + + +
    +
    + + {$t("pages.me.manage.motivatingProjects.title")} + +

    + {$t("pages.me.manage.motivatingProjects.subtitle")} +

    +
    + + onChange?.(next)} /> +
    diff --git a/src/components/profile/manage/ProfileHeader.svelte b/src/components/profile/manage/ProfileHeader.svelte new file mode 100644 index 00000000..aa268726 --- /dev/null +++ b/src/components/profile/manage/ProfileHeader.svelte @@ -0,0 +1,30 @@ + + + +
    +
    + {title} +

    {subtitle}

    +
    + +
    diff --git a/src/components/profile/manage/ProfileIdentityRow.svelte b/src/components/profile/manage/ProfileIdentityRow.svelte new file mode 100644 index 00000000..1b53914e --- /dev/null +++ b/src/components/profile/manage/ProfileIdentityRow.svelte @@ -0,0 +1,54 @@ + + + +
    + + + +
    + + {$t("pages.me.manage.bio.title")} + +

    {$t("pages.me.manage.bio.subtitle")}

    +
    + +
    + + +
    diff --git a/src/components/review/ReviewAreas.svelte b/src/components/review/ReviewAreas.svelte new file mode 100644 index 00000000..32cab7b0 --- /dev/null +++ b/src/components/review/ReviewAreas.svelte @@ -0,0 +1,191 @@ + + +
    + {errorMessage} +
    + +
    +
    + + {title} + + {#if subtitle} +

    {subtitle}

    + {/if} +
    +
    + {#if canChangeStatus} + + + {:else if projectStatus} + + {$t(`domain.project.status.${projectStatus}`)} + + {/if} +
    +
    +
    + + {#if reviewedAreas.length} + + {#each reviewedAreas as area (area.id)} + + {/each} + + {:else} +

    + {$t("pages.review.areas.empty")} +

    + {/if} +
    diff --git a/src/components/review/ReviewChat.svelte b/src/components/review/ReviewChat.svelte new file mode 100644 index 00000000..e70d8bee --- /dev/null +++ b/src/components/review/ReviewChat.svelte @@ -0,0 +1,255 @@ + + +
    + {errorMessage} +
    + + {$t("pages.review.btns.back")} + + +
    +
    + + {area.title} + + {#if currentRisk} + {$t(`domain.review.risks.${currentRisk}`)} + {/if} +
    + {#if canEditRisk} + + handleRiskChange(risk as ProjectReviewRisk)} + /> + {/if} +
    +
    + +
    + {#if comments.length} + {#each comments as comment, index (comment.id)} + {#if opensNewDay(comments[index - 1], comment)} +
    + + {dayLabel(comment)} + +
    + {/if} + {@const author = authorOf(comment.author)} + {#if comment.system} + + {:else} + + {/if} + {/each} + {:else} +

    + {$t("pages.review.chat.empty")} +

    + {/if} +
    + + +
    diff --git a/src/components/review/ReviewOptionsModal.svelte b/src/components/review/ReviewOptionsModal.svelte new file mode 100644 index 00000000..f3bc2344 --- /dev/null +++ b/src/components/review/ReviewOptionsModal.svelte @@ -0,0 +1,90 @@ + + + + {#snippet header()} + + {title} + + {/snippet} + + + + {#snippet footer()} + + {/snippet} + diff --git a/src/components/review/RiskChangeCard.svelte b/src/components/review/RiskChangeCard.svelte new file mode 100644 index 00000000..669f79f1 --- /dev/null +++ b/src/components/review/RiskChangeCard.svelte @@ -0,0 +1,32 @@ + + +
    +
    + + {$t("pages.review.chat.riskChange.title")} + + {$t(`domain.review.risks.${to}`)} +
    +

    + {$t("pages.review.chat.riskChange.description", { + from: $t(`domain.review.risks.${from}`).toLowerCase(), + to: $t(`domain.review.risks.${to}`).toLowerCase(), + })} +

    +
    diff --git a/src/components/review/RisksCard.svelte b/src/components/review/RisksCard.svelte new file mode 100644 index 00000000..896beac2 --- /dev/null +++ b/src/components/review/RisksCard.svelte @@ -0,0 +1,95 @@ + + +
    +
    +
    +
    + + {area.title} + + {#if activity} + {activity} + {/if} +
    + {#if area.risk} + {$t(`domain.review.risks.${area.risk}`)} + {/if} +
    +

    + {area.summary} +

    +
    +
    + + {#if canEditRisk} + + onRiskChange?.(area, risk as ProjectReviewRisk)} + /> + {/if} +
    +
    diff --git a/src/components/search/Search.svelte b/src/components/search/Search.svelte new file mode 100644 index 00000000..e387ebe7 --- /dev/null +++ b/src/components/search/Search.svelte @@ -0,0 +1,209 @@ + + + +
    + +
    + +
    + +
    + {#if hasSearched && !isLoading && campaigns.length > 0} + {$t("pages.search.accessibility.resultsFound", { count: totalItems })} + {/if} +
    + + {#if error} +
    + +
    + {/if} + +
    + + {#if !hasSearched || (isLoading && campaigns.length === 0)} +
    + +
    + {/if} + + + {#if campaigns.length > 0} +
    + + {#if isLoading && !isLoadingMore} +
    + +
    + {/if} + + {#each campaigns as campaign (campaign.id)} +
    + +
    + {/each} +
    +
    + +
    + +
    + {/if} +
    + + {#if isEmpty && !isLoading} +
    + + + {$t("pages.search.empty.title")} + +

    + {$t("pages.search.empty.description")} +

    + +
    + {/if} +
    diff --git a/src/components/search/SearchFilters.svelte b/src/components/search/SearchFilters.svelte index 59f5667b..d5012e0f 100644 --- a/src/components/search/SearchFilters.svelte +++ b/src/components/search/SearchFilters.svelte @@ -2,55 +2,31 @@ Search Filters Container Component Main container that composes all filter subcomponents Implements responsive layout matching Figma design with collapsible mobile behavior -Integrated with searchStore for state management and URL synchronization --> - -{#if $hasPrevPage || $hasNextPage} - -{/if} diff --git a/src/components/search/SearchResultsClient.svelte b/src/components/search/SearchResultsClient.svelte deleted file mode 100644 index 85c0c3fe..00000000 --- a/src/components/search/SearchResultsClient.svelte +++ /dev/null @@ -1,278 +0,0 @@ - - - - -
    - - -
    - - {#if $searchError} -
    - -
    - {/if} - - -
    - - {#if ($isSearching || isTransforming) && campaigns.length === 0} -
    - -
    - {/if} - - - {#if campaigns.length > 0} -
    - - {#if $isSearching && !$isLoadingMore} -
    - -
    - {/if} - - {#each campaigns as campaign} - -
    - -
    - {/each} -
    -
    - {/if} -
    - - - {#if $hasActualSearchResults && !isTransforming} -
    - searchStore.loadMoreResults()} - isLoading={$isSearching} - hasMore={$hasNextPage} - loadedCount={$searchResults.length} - /> -
    - {/if} - - {#if $isEmpty && !$isSearching && !isTransforming} - -
    - - - {$t("pages.search.empty.title")} - -

    - {$t("pages.search.empty.description")} -

    - -
    - {/if} - - {#if !$hasSearchResults && !$isEmpty && !$isSearching && !isTransforming} - -
    - - {$t("pages.search.initial.title")} - -

    {$t("pages.search.initial.description")}

    -
    - {/if} -
    - - diff --git a/src/config/footer.ts b/src/config/footer.ts deleted file mode 100644 index cb2bf0c6..00000000 --- a/src/config/footer.ts +++ /dev/null @@ -1,149 +0,0 @@ -import Facebook from "../components/icons/social/Facebook.svelte"; -import Gmail from "../components/icons/social/Gmail.svelte"; -import Instagram from "../components/icons/social/Instagram.svelte"; -import Linkedin from "../components/icons/social/Linkedin.svelte"; -import X from "../components/icons/social/X.svelte"; - -import type { FooterConfig } from "../types/footer"; - -export const FOOTER_CONFIG: FooterConfig = { - funding: [ - { src: "/images/partners/NGEU.png", alt: "Funded by the European Union NextGeneration EU" }, - { src: "/images/partners/MTES.png", alt: "Ministerio de Trabajo y Economía Social" }, - { - src: "/images/partners/PRTR.png", - alt: "Plan de Recuperación, Transformación y Resiliencia", - }, - ], - partOf: [ - { - src: "/images/partners/xes.png", - alt: "XES - Xarxa d'Economia Solidària", - href: "https://xes.cat", - size: "medium", - class: "rounded-lg", - }, - { - src: "/images/partners/alda-europe.svg", - alt: "Alda Europe - European Association for Local Democracy", - href: "https://www.alda-europe.eu", - size: "medium", - class: "rounded-lg", - }, - { - src: "/images/partners/alianza-por-el-clima.webp", - alt: "Alianza por el Clima", - href: "https://alianzaporelclima.org", - size: "medium", - class: "rounded-lg", - }, - { - src: "/images/partners/ampliando-democracia.jpg", - alt: "Ampliando Democracia", - href: "https://ampliandodemocracia.org", - size: "medium", - class: "rounded-lg", - }, - ], - navColumns: [ - { - titleKey: "common.footer.navigation.collaborate.title", - links: [ - { - href: "/create-project", - labelKey: "common.footer.navigation.collaborate.createProject", - }, - { - href: "/matchfunding", - labelKey: "common.footer.navigation.collaborate.matchfunding", - }, - { href: "/workshops", labelKey: "common.footer.navigation.collaborate.workshops" }, - ], - }, - { - titleKey: "common.footer.navigation.help.title", - links: [ - { href: "/faqs", labelKey: "common.footer.navigation.help.faqs" }, - { href: "/contact", labelKey: "common.footer.navigation.help.contact" }, - { - href: "/tax-calculator", - labelKey: "common.footer.navigation.help.taxCalculator", - }, - ], - }, - { - titleKey: "common.footer.navigation.aboutGoteo.title", - links: [ - { href: "/about", labelKey: "common.footer.navigation.aboutGoteo.whoWeAre" }, - { - href: "/benefits", - labelKey: "common.footer.navigation.aboutGoteo.goteoBenefits", - }, - { - href: "/impact", - labelKey: "common.footer.navigation.aboutGoteo.foundationImpact", - }, - { - href: "/platoniq", - labelKey: "common.footer.navigation.aboutGoteo.platoniqFoundation", - }, - ], - }, - { - titleKey: "common.footer.navigation.forUsers.title", - links: [ - { href: "/wallet", labelKey: "common.footer.navigation.forUsers.rechargeWallet" }, - // GOTEO-OC-DONATION-CERTIFICATE: "Descarga certificado" link is exclusive to - // the donation-certificate feature of Goteo under Fundación Platoniq, hidden - // in the open-core. - // { href: "/certificate", labelKey: "common.footer.navigation.forUsers.downloadCertificate" }, - { - href: "/funds", - labelKey: "common.footer.navigation.forUsers.accessComplementaryFunds", - }, - { - href: "/program", - labelKey: "common.footer.navigation.forUsers.designFundingProgram", - }, - ], - }, - ], - legalLinks: [ - { href: "/terms", labelKey: "common.footer.legal.termsOfUse" }, - { href: "/privacy", labelKey: "common.footer.legal.privacyPolicy" }, - { href: "/licenses", labelKey: "common.footer.legal.licenses" }, - { href: "/stats", labelKey: "common.footer.legal.stats" }, - ], - social: [ - { - icon: Gmail, - href: "mailto:info@goteo.org", - ariaLabelKey: "common.footer.a11y.social.email", - }, - { - icon: Instagram, - href: "https://instagram.com/goteofunding", - ariaLabelKey: "common.footer.a11y.social.instagram", - }, - { - icon: Facebook, - href: "https://facebook.com/goteofunding", - ariaLabelKey: "common.footer.a11y.social.facebook", - }, - { - icon: X, - href: "https://twitter.com/goteofunding", - ariaLabelKey: "common.footer.a11y.social.x", - }, - { - icon: Linkedin, - href: "https://www.linkedin.com/company/platoniqlab", - ariaLabelKey: "common.footer.a11y.social.linkedin", - }, - ], - brand: { - backgroundImage: "/images/brand/path2.svg", - logo: { src: "/images/brand/goteo-white-logo.svg", alt: "Goteo logo" }, - platoniq: { src: "/images/brand/platoniq.png", alt: "Platoniq Foundation logo" }, - }, -}; diff --git a/src/firewall/access-control.ts b/src/firewall/access-control.ts index f4bd6f6d..7829eece 100644 --- a/src/firewall/access-control.ts +++ b/src/firewall/access-control.ts @@ -8,8 +8,22 @@ export const ACL: ControlItem[] = [ path: "/project/.*/edit", roles: ["ROLE_USER"], }, + { + path: "/project/.*/manage", + roles: ["ROLE_USER"], + }, + { + path: "/reviews", + roles: ["ROLE_USER", "ROLE_ADMIN"], + }, { path: "/admin", roles: ["ROLE_ADMIN"], }, ]; + +/** + * Paths that can trust the session stored in the cookie, skipping the User re-fetch + * from the API. Every other path gets the User's current roles on each request. + */ +export const CACHED_SESSION_PATHS: string[] = ["/api/relay"]; diff --git a/src/firewall/index.ts b/src/firewall/index.ts index ddc63c3b..29d384df 100644 --- a/src/firewall/index.ts +++ b/src/firewall/index.ts @@ -1,4 +1,4 @@ -import { ACL, type ControlItem } from "./access-control"; +import { ACL, CACHED_SESSION_PATHS, type ControlItem } from "./access-control"; import { isSupportedLocale } from "../i18n/locales"; const regexCache = new Map(); @@ -67,6 +67,17 @@ export function getMatchingACL(pathname: string): ControlItem | null { return null; } +/** + * Checks if a pathname can use the session stored in the cookie without re-fetching the User. + * @param pathname The pathname to check, e.g. "/api/relay/v4/projects" + * @returns True if the cached session is enough, false if it must be refreshed + */ +export function allowsCachedSession(pathname: string): boolean { + const normalized = normalizePath(pathname); + + return CACHED_SESSION_PATHS.some((path) => matchesPath(normalized, path)); +} + /** * Checks if a user is authorized to access a control item. * @param control The control item (ACL) to check authorization for diff --git a/src/i18n/locales/ca.json b/src/i18n/locales/ca.json index 56e31952..9fa16cfd 100644 --- a/src/i18n/locales/ca.json +++ b/src/i18n/locales/ca.json @@ -623,13 +623,13 @@ "userDonations": "Les teves donacions:" }, "blog": { - "title": "S'acosten grans canvis… no et perdis res!", - "excerpt": "Un procés que implica una important migració tècnica de la plataforma actual, i per fer-ho amb totes les garanties de seguretat i sense pèrdua d'informació, necessitem dur a terme una transició ordenada, amb dates clau per a la publicació de campanyes i la realització de donacions…", + "title": "DTF de Goteo ja està en marxa. Descobreix què s'està gestant.", + "excerpt": "Obrim un nou espai per finançar la tecnologia democràtica d'una manera diferent. Descobreix les primeres «Shelves», coneix les iniciatives que s'hi sumen i segueix les comunitats que construeixen infraestructures per a la memòria democràtica, l'autonomia digital i el poder col·lectiu.", "readMore": "Continuar llegint" }, "matchfunding": { - "title": "Matchfunding", - "description": "L'efecte match només el veuràs a Goteo. Des del 2013 busquem organitzacions que vulguin multiplicar l'impacte del crowdfunding. Per cada euro que una persona aporta a una campanya de Goteo, una institució col·laboradora (fundació, administració pública, empresa…) aporta la mateixa quantitat, o fins i tot més.", + "title": "Prestatgeries de DTF", + "description": "Les «Shelves» (prestatgeries) agrupen projectes al voltant d'un repte compartit, des de l'autonomia digital i les infraestructures distribuïdes fins a les cures, el treball o la participació democràtica. Cada «Shelf» està impulsada per un *Steward*: una organització o coalició que aporta la seva comunitat, el seu coneixement i les seves prioritats de finançament a DTF by Goteo.", "actions": { "learnMore": "Vull saber-ne més", "viewCalls": "Veure convocatòries actives" @@ -1070,8 +1070,8 @@ }, "anonymous": { "length": "aportacions", - "title": "Donacions anònimes", - "description": "No totes les persones heroiques porten capa ni tampoc senten la necessitat de mostrar la seva identitat." + "title": "Usuaris de Goteo.org", + "description": "" }, "loadMore": "Veure més" } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 365f963c..b91d59a9 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -623,13 +623,13 @@ "userDonations": "Your donations:" }, "blog": { - "title": "Big changes are coming… don't miss a thing!", - "excerpt": "A process involving a major technical migration of the current platform, and to do it with full security guarantees and without losing information, we need to carry out an orderly transition, with key dates for publishing campaigns and making donations…", + "title": "DTF crowd by Goteo is live. Meet what’s taking shape.", + "excerpt": "We’re opening a new space to fund democratic technology differently. Discover the first Shelves, meet the initiatives joining them, and follow the communities building infrastructures for democratic memory, digital autonomy and collective power.", "readMore": "Continue reading" }, "matchfunding": { - "title": "Matchfunding", - "description": "You'll only see the match effect on Goteo. Since 2013, we have been looking for organizations that want to multiply the impact of crowdfunding. For every euro a person contributes to a Goteo campaign, a partner institution (foundation, public administration, company…) contributes the same amount, or even more.", + "title": "DTF Shelves", + "description": "Shelves bring together projects around a shared challenge, from digital autonomy and distributed infrastructures to care, labour or democratic participation. Each Shelf is hosted by a Steward: an organisation or coalition that brings its community, knowledge and funding priorities to DTF by Goteo.", "actions": { "learnMore": "I want to learn more", "viewCalls": "View active calls" @@ -1070,8 +1070,8 @@ }, "anonymous": { "length": "contributions", - "title": "Anonymous donations", - "description": "Not all heroic people wear capes, nor do they feel the need to reveal their identity." + "title": "Goteo.org users", + "description": "" }, "loadMore": "See more" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 2cd91289..11241aa4 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -266,18 +266,28 @@ }, "imageUploadModal": { "title": "Sube tus imágenes", - "description": "Añade imágenes o vídeos aquí. El formato idóneo para Goteo es {{ size }}", + "description": "Añade imágenes o vídeos aquí. Tamaño recomendado {{ size }}", "dropzone": "Arrastra tus archivos aquí o haz clic para seleccionarlos." }, "richTextEditor": { "toolbar": "Barra de formato de texto", - "fontSize": "Tamaño de letra", + "textStyle": "Estilo de texto", + "paragraph": "Texto normal", + "heading": "Título {{ level }}", "bold": "Negrita", "italic": "Cursiva", "alignLeft": "Alinear a la izquierda", "alignCenter": "Centrar", "alignRight": "Alinear a la derecha", "image": "Insertar imagen", + "link": "Insertar enlace", + "linkUrl": "URL del enlace", + "linkApply": "Aplicar", + "linkRemove": "Quitar enlace", + "linkCancel": "Cancelar", + "bulletList": "Lista con viñetas", + "orderedList": "Lista numerada", + "quote": "Cita", "characterCount": "{{ current }} / {{ max }} caracteres" }, "export": { @@ -336,6 +346,7 @@ "viewLess": "Ver menos", "share": "Compartir", "noData": "No hay datos disponibles", + "noOptions": "No hay opciones disponibles", "tooltip": { "copied": "¡Copiado!" }, @@ -621,14 +632,11 @@ }, "home": { "banner": { - "title": "¡Renovamos Goteo!", - "description": "Goteo ha construido una historia única en el mundo del micromecenazgo. ¿Quieres seguir formando parte de ella?", - "ctaText": "Súmate a nuestra historia", "closeAriaLabel": "Cerrar banner" }, "campaigns": { "title": "Campañas más recientes", - "tabsTitle": "Campañas en Goteo", + "tabsTitle": "Algunos de nuestros proyectos", "tabs": { "endingSoon": "Acaban pronto", "mostSuccessful": "Más exitosas", @@ -638,22 +646,20 @@ "obtained": "Obtenido", "minimum": "Mínimo", "optimum": "Óptimo", - "daysRemaining": "Quedan {{days}} días", + "daysRemaining": "Faltan {{days}} días", + "finished": "Finalizada", "matchfunding": "Matchfunding", - "status": { - "minimumReached": "¡Mínimo conseguido!", - "goForMinimum": "¡A por el mínimo!" - }, + "minimumExceeded": "¡Mínimo superado!", "userDonations": "Tus donaciones:" }, "blog": { - "title": "Se avecinan grandes cambios… ¡no te pierdas nada!", - "excerpt": "Un proceso que implica una importante migración técnica de la plataforma actual, y para hacerlo con todas las garantías de seguridad y sin pérdida de información, necesitamos llevar a cabo una transición ordenada, con fechas clave para la publicación de campañas y la realización de donaciones…", + "title": "DTF crowd de Goteo ya está en marcha. Descubre lo que se está gestando.", + "excerpt": "Abrimos un nuevo espacio para financiar la tecnología democrática de una manera diferente. Descubre los primeros «Shelves», conoce las iniciativas que se suman a ellos y sigue a las comunidades que construyen infraestructuras para la memoria democrática, la autonomía digital y el poder colectivo.", "readMore": "Seguir leyendo" }, "matchfunding": { - "title": "Matchfunding", - "description": "El efecto match sólo lo verás en Goteo. Desde el 2013 buscamos organizaciones que quieran multiplicar el impacto del crowdfunding. Por cada euro que una persona aporta a una campaña de Goteo, una institución colaboradora (fundación, administración pública, empresa…) aporta la misma cantidad, o incluso más.", + "title": "Estanterías de DTF", + "description": "Las «Estanterías» (Shelves) agrupan proyectos en torno a un desafío común, que abarca desde la autonomía digital y las infraestructuras distribuidas hasta los cuidados, el trabajo o la participación democrática. Cada Estantería está coordinada por un «Custodio» (Steward): una organización o coalición que aporta su comunidad, sus conocimientos y sus prioridades de financiación a DTF by Goteo.", "actions": { "learnMore": "Quiero saber más", "viewCalls": "Ver convocatorias activas" @@ -1102,8 +1108,8 @@ }, "anonymous": { "length": "aportes", - "title": "Donaciones anónimas", - "description": "No todas las personas heróicas llevan capa ni tampoco sienten la necesidad de mostrar su identidad." + "title": "Usuarios de Goteo.org", + "description": "" }, "loadMore": "Ver más" } @@ -1153,6 +1159,49 @@ "not-found": "Disculpa, esta campaña no tiene vídeo de presentación", "not-supported": "No ha sido posible cargar el vídeo. Por favor, revisa el enlace o contacta con el administrador de la campaña." } + }, + "manage": { + "title": "Gestionando proyecto: {{ project }}", + "subtitle": "Consulta tu actividad", + "editProject": "Editar proyecto", + "itemsPerPage": "Líneas por página:", + "error": "No se ha podido guardar el cambio", + "rewards": { + "title": "Recompensas" + }, + "claims": { + "title": "Gestión de recompensas", + "search": "Busca usuario...", + "noData": "Todavía no hay recompensas reclamadas", + "headers": { + "user": "Usuario", + "address": "Dirección de envío", + "reward": "Recompensa", + "status": "Estado", + "action": "Acción" + }, + "changeStatus": "Cambiar estado", + "statuses": { + "fulfilled": "Entregada", + "in_pending": "Pendiente" + }, + "modal": { + "title": "Cambiar estado de recompensa", + "current": "Actualmente la recompensa está en estado {{ status }}", + "placeholder": "Selecciona el estado", + "save": "Guardar estado" + } + }, + "collaborations": { + "title": "Gestión de colaboraciones", + "noData": "Todavía no hay candidaturas a colaboraciones", + "headers": { + "user": "Usuario", + "type": "Tipo de colaboración", + "assign": "Asignar" + }, + "search": "Busca usuario..." + } } }, "about": { @@ -1239,19 +1288,54 @@ } }, "review": { + "title": "Revisión de {{ project }}", + "titlePromoter": "Asesoría con @{{ reviewer }} para {{ project }}", + "titleReviewer": "Asesoría con @{{ promoter }} del proyecto {{ project }}", + "titleFallback": "Asesoría del proyecto {{ project }}", + "subtitleReviewer": "Aquí encontrarás toda la información sobre esta conversación con {{ promoter }}", + "subtitlePromoter": "Aquí encontrarás toda la información sobre esta conversación con {{ reviewer }}", + "subtitleFallback": "Aquí encontrarás toda la información sobre esta conversación", + "project": { + "label": "Proyecto" + }, + "status": { + "label": "Estado de la revisión" + }, + "areas": { + "title": "Áreas de riesgo", + "empty": "Esta revisión todavía no tiene áreas de riesgo." + }, "btns": { - "seeChat": "Ver conversación", - "changeRisk": "Cambiar riesgo" + "seeChat": "Ver detalle", + "changeRisk": "Cambiar riesgo", + "changeStatus": "Cambiar estado", + "back": "Volver a la revisión" }, "card": { - "activity": "{{ count }} chats. Última actividad {{ date }}" + "activity": "{{ count }} mensajes. Última actividad {{ date }}" }, "chat": { - "placeholder": "Escribe un mensaje…" + "title": "{{ area }} · {{ project }}", + "placeholder": "Escribe un mensaje…", + "send": "Enviar mensaje", + "empty": "Todavía no hay mensajes en esta conversación. Empieza tú.", + "today": "Hoy", + "yesterday": "Ayer", + "day": "{{ date }}", + "riskChange": { + "title": "El proyecto ha cambiado el riesgo", + "description": "La asesora asignada ha modificado el riesgo del proyecto, de {{from}} a {{to}}" + } + }, + "errors": { + "forbidden": "No tienes permiso para realizar esta acción.", + "notFound": "La revisión no existe.", + "areaNotFound": "El área de riesgo no existe.", + "statusNotUpdated": "No se ha podido actualizar el estado del proyecto." } }, "search": { - "title": "Buscar Proyectos - Goteo", + "title": "Buscar Proyectos", "description": "Explora nuestro catálogo de proyectos y encuentra la que más te inspire para hacer tu donación", "heading": "Encuentra los Proyectos que buscas", "subtitle": "Explora nuestro catálogo de proyectos y elige la que más te inspire para hacer tu donación", @@ -1401,7 +1485,8 @@ "projects": "Proyectos", "users": "Usuarios", "comm": "Comunicación", - "home": "Portada" + "home": "Portada", + "reviews": "Revisiones" }, "sidebar": { "aria": { @@ -1424,24 +1509,42 @@ "description": "El inicio de la portada, lo más importante.", "errors": { "forbidden": "No tienes permisos para gestionar el hero de la portada", + "invalidUrl": "La URL del enlace no es válida", + "invalidTranslations": "Las traducciones enviadas no son válidas", + "incompleteLanguages": "Faltan campos obligatorios en: {{ languages }}" + }, + "addTranslation": "Añadir traducción", + "addTranslationModal": { + "title": "Añade una nueva traducción", + "description": "Busca y elige el idioma al que quieres traducir el contenido del hero", + "searchPlaceholder": "Busca entre todos los idiomas…", + "submit": "Añadir" + }, + "removeTranslation": "Eliminar traducción", + "removeTranslationModal": { + "title": "¿Eliminar esta traducción?", + "description": "La traducción se eliminará del formulario y no se guardará.", "invalidUrl": "La URL del enlace no es válida" }, "tabs": { "fields": "Nuevo", - "history": "Histórico" + "history": "Registro" }, "history": { - "title": "Histórico de bloques", + "title": "Registro de bloques", "description": "Aquí puedes ver los bloques programados y eliminar los que no quieras publicar", - "noData": "No hay bloques en el histórico", + "noData": "No hay bloques en el registro", "filter": { "all": "Todos", - "upcoming": "Programado" + "upcoming": "Programados" }, + "searchPlaceholder": "Busca por ID, título o contenido", "headers": { + "id": "ID", "title": "Título", "content": "Contenido", "startsAt": "Fecha de inicio", + "languages": "Idiomas", "status": "Estado" }, "status": { @@ -1959,6 +2062,48 @@ } } } + }, + "reviews": { + "description": "Revisa los proyectos enviados a revisión y gestiona su estado.", + "projects": { + "title": "Revisión de Proyectos", + "description": "Gestiona y supervisa el estado de tus proyectos de crowdfunding de forma centralizada.", + "list": "Listado de proyectos:", + "totalizers": { + "selected": "Proyectos seleccionados:" + }, + "table": { + "headers": { + "name": "Proyecto", + "promoter": "Impulsor", + "dateSubmitted": "Envío a revisión", + "status": "Estado", + "advisor": "Asesor" + }, + "rows": { + "unassigned": "Sin asignar", + "details": { + "email": "Contacto mail:", + "phone": "Teléfono:", + "dateRelease": "Lanzamiento", + "dateCreated": "Creación", + "lastInteraction": "Última interacción", + "risk": "Riesgo", + "pendingMessages": "Mensajes pendientes", + "minOptim": "Mínimo - Óptimo:", + "lastModified": "Última modificación:", + "btns": { + "preview": "Preview proyecto", + "promoter": "Perfil de impulsor", + "config": "Config. proyecto", + "advisory": "Asesoría de proyecto", + "assignAdvisor": "Asignar asesor", + "annotations": "Anotaciones" + } + } + } + } + } } }, "static": { @@ -1978,9 +2123,10 @@ "noProjects": "No hay proyectos impulsados aún.", "noBio": "Todavía no hay información sobre este perfil.", "donorType": { + "noProjects": "Aún no ha donado a ningún proyecto.", "fan": { "title": "Fan", - "description": "Este donante ha invertido más de 25€", + "description": "Este donante ha invertido {{ amount }}", "projectsDonated": "Proyectos donados", "moneyDonated": "Dinero donado" }, @@ -2008,7 +2154,109 @@ "me": { "page": { "title": "Actividad", - "subtitle": "Consulta tu actividad en Goteo" + "subtitle": "Consulta tu actividad" + }, + "nav": { + "activity": "Actividad", + "wallet": "Monedero", + "inbox": "Buzón", + "profile": "Perfil" + }, + "manage": { + "title": "Perfil de impulsor", + "subtitle": "Presenta proyectos o financia iniciativas. Completa tu perfil con una buena presentación, enlaces relevantes y una foto que te identifique.", + "sidebar": { + "publicProfile": "Perfil público", + "personalProfile": "Perfil personal" + }, + "profile": { + "uploadImage": "Sube nueva imagen", + "avatarAlt": "Imagen de perfil", + "type": { + "individual": "Perfil impulsor · Persona física", + "organization": "Perfil impulsor · Persona jurídica" + } + }, + "bio": { + "title": "Bio", + "subtitle": "Cuéntanos algo sobre ti", + "placeholder": "Escribe una breve presentación" + }, + "info": { + "title": "Información del impulsor", + "subtitle": "Cuéntanos algo sobre ti", + "handle": "Nombre de usuario", + "firstName": "Nombre", + "lastName": "Apellidos", + "legalName": "Razón social", + "businessName": "Nombre comercial", + "country": "País", + "locality": "Localidad", + "localityPlaceholder": "Busca tu localidad", + "address": "Dirección (indicar si es calle, plaza, avenida, etc)", + "type": "Forma jurídica", + "taxId": "Número de documento", + "representative": "Datos del representante" + }, + "location": { + "share": "*Compartir mi ubicación", + "disclaimer": "*Compartir tu ubicación con nuestro equipo servirá para generar información (anónima, sin revelar nunca a terceros tus datos personales) así como indicadores de impacto que hagan más eficaz nuestro trabajo y el apoyo a proyectos." + }, + "social": { + "title": "Redes sociales", + "subtitle": "Añade tus redes para mostrárselas al mundo", + "instagram": "Inserte su cuenta de Instagram", + "facebook": "Inserte su cuenta de Facebook", + "x": "Inserte su cuenta de X", + "linkedin": "Inserte su cuenta de Linkedin", + "placeholder": "Usuario o enlace" + }, + "motivatingProjects": { + "title": "Proyectos que te motivan", + "subtitle": "Indica el tipo de proyectos que pueden conectar con tus intereses para cofinanciarlos y/o aportar con otros recursos, conocimientos o habilidades. Estas categorías son transversales, puedes seleccionar más de una." + }, + "personal": { + "title": "Perfil personal", + "subtitle": "Estos datos no se muestran en tu perfil público. Solo tú y nuestro equipo pueden verlos, y los usamos para gestionar tus impuestos, facturas y certificados.", + "info": { + "title": "Información personal", + "subtitle": "Tal y como aparecen en tu documentación.", + "postalCode": "Código postal", + "anonymousDonation": "Al realizar una donación mantener mi perfil anónimo", + "howDidYouMeet": "¿Cómo nos conociste?", + "documentType": "Tipo de documento", + "birthYear": "Año de nacimiento", + "gender": "Sexo" + }, + "success": "Datos personales actualizados correctamente", + "error": "No se pudieron guardar los datos personales. Inténtalo de nuevo más tarde." + }, + "viewProfile": "Ver mi perfil", + "save": "Guardar cambios", + "success": "Perfil actualizado correctamente", + "error": "No se pudo guardar el perfil. Inténtalo de nuevo más tarde.", + "validation": { + "required": "Este campo es obligatorio", + "handleInvalid": "Solo se permiten minúsculas, números y guiones bajos (4-30 caracteres)", + "taxIdInvalid": "El número no tiene un formato válido para el país seleccionado", + "linkInvalid": "Introduce tu usuario o un enlace a tu perfil de esta red social" + }, + "preferences": { + "title": "Preferencias", + "subtitle": "Gestiona tus preferencias de perfil para que todo fluya a tu gusto", + "nav": { + "preferences": "Preferencias" + }, + "languages": { + "title": "Idioma de la interfaz", + "label": "Selecciona un idioma", + "helper": "La aplicación usará este idioma para la interfaz e intentará servirte contenido primero en este idioma" + }, + "save": "Guardar preferencias", + "toast": { + "saved": "Preferencias guardadas correctamente" + } + } }, "period": { "label": "Periodo", diff --git a/src/i18n/utils.ts b/src/i18n/utils.ts index 9aa2ec61..e2dfcfee 100644 --- a/src/i18n/utils.ts +++ b/src/i18n/utils.ts @@ -64,7 +64,7 @@ export function useTranslations(lang: T) { * @param {string} unsafe - The string to escape. * @returns {string} - The escaped string. */ -function escapeHTML(unsafe: string): string { +export function escapeHTML(unsafe: string): string { return unsafe.replace(/[&<>"']/g, (match) => { const map: Record = { "&": "&", diff --git a/src/layouts/App.svelte b/src/layouts/App.svelte index 71edb686..c5f9520a 100644 --- a/src/layouts/App.svelte +++ b/src/layouts/App.svelte @@ -7,6 +7,7 @@ import Footer from "./Footer.svelte"; import Header from "./Header.svelte"; import HeaderSubmenu from "./HeaderSubmenu.svelte"; + import MeHeader from "./MeHeader.svelte"; import { session } from "../auth/store"; import { locale } from "../i18n/store"; import { CACHE_NAME } from "../openapi/cacheFetch"; @@ -22,6 +23,11 @@ children?: Snippet; class?: ClassNameValue; admin?: boolean; + /** + * Header of the signed-in promoter area. Mutually exclusive with `admin`: the two are + * separate dashboards and the admin one wins if a page ever asks for both. + */ + me?: boolean; } let { @@ -30,6 +36,7 @@ children, class: classes, admin = false, + me = false, }: AppState = $props(); const HAD_SESSION_KEY = "goteo-had-session"; @@ -128,6 +135,8 @@ {#if admin} +{:else if me} + {:else}
    diff --git a/src/layouts/Footer.svelte b/src/layouts/Footer.svelte index c1fe42d7..85f238e7 100644 --- a/src/layouts/Footer.svelte +++ b/src/layouts/Footer.svelte @@ -1,8 +1,8 @@ @@ -10,64 +10,70 @@ -
    -
    -
    - -
    - - {$t("common.footer.funding.title")} - -
    -
    - {#each config.funding as partner (partner.src)} - - {/each} + {#if config.funding.length > 0 || config.partOf.length > 0} +
    +
    +
    + {#if config.funding.length > 0} + +
    + + {$t("common.footer.funding.title")} + +
    +
    + {#each config.funding as partner (partner.src)} + + {/each} +
    +
    -
    -
    + {/if} - -
    - - {$t("common.footer.funding.partOf")} - -
    - {#each config.partOf as partner (partner.src)} - - {/each} -
    + {#if config.partOf.length > 0} + +
    + + {$t("common.footer.funding.partOf")} + +
    + {#each config.partOf as partner (partner.src)} + + {/each} +
    +
    + {/if}
    -
    -
    + + {/if}
    @@ -88,12 +94,7 @@ {#each config.navColumns as column (column.titleKey)}
    - + <Title level={4} variant="field" class="leading-6"> {$t(column.titleKey)}
      @@ -110,7 +111,15 @@ -
      +
      +
      + {config.brand.logo.alt} +
      @@ -168,7 +177,7 @@
      -
      +
      {config.brand.platoniq.alt}
      -
      {:else} - + {/if} @@ -120,15 +124,9 @@ -
    • - -
    • +
    diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index 641f83da..0bd1a311 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -24,6 +24,7 @@ const preloadedFaces: FontPreloadFilter = [{ style: "normal", subset: "latin" }] {title} + diff --git a/src/layouts/MeHeader.svelte b/src/layouts/MeHeader.svelte new file mode 100644 index 00000000..16e4d81c --- /dev/null +++ b/src/layouts/MeHeader.svelte @@ -0,0 +1,12 @@ + + +
    + {#snippet nav()} + + {/snippet} + +
    diff --git a/src/layouts/footer-data.ts b/src/layouts/footer-data.ts new file mode 100644 index 00000000..3c5e2684 --- /dev/null +++ b/src/layouts/footer-data.ts @@ -0,0 +1,46 @@ +import Mastodon from "../components/icons/social/Mastodon.svelte"; + +import type { FooterConfig } from "../types/footer"; + +export const FOOTER_DATA: FooterConfig = { + funding: [ + { src: "/images/partners/PLATONIQ.png", alt: "Platoniq Foundation" }, + { src: "/images/partners/FKI.svg", alt: "Free Knowledge Institute" }, + { src: "/images/partners/COMMONSNETWORK.jpeg", alt: "Commons Network" }, + { src: "/images/partners/OPENTECH.png", alt: "OpenTech AUC" }, + ], + partOf: [], + navColumns: [ + { + titleKey: "About", + links: [ + { + href: "/about", + labelKey: "What is Goteo?", + }, + { + href: "/static/dtf", + labelKey: "What is DTF?", + }, + ], + }, + ], + legalLinks: [ + { href: "/terms", labelKey: "common.footer.legal.termsOfUse" }, + { href: "/privacy", labelKey: "common.footer.legal.privacyPolicy" }, + { href: "/licenses", labelKey: "common.footer.legal.licenses" }, + { href: "/stats", labelKey: "common.footer.legal.stats" }, + ], + social: [ + { + icon: Mastodon, + href: "https://mastodon.social/@platoniq", + labelKey: "Mastodon", + }, + ], + brand: { + backgroundImage: "/images/brand/path2.svg", + logo: { src: "/images/brand/goteo-white-logo.svg", alt: "Goteo logo" }, + platoniq: { src: "/images/brand/platoniq.png", alt: "Platoniq Foundation logo" }, + }, +}; diff --git a/src/middleware/firewall.ts b/src/middleware/firewall.ts index 2c586017..ef00b22a 100644 --- a/src/middleware/firewall.ts +++ b/src/middleware/firewall.ts @@ -1,7 +1,8 @@ -import { getSession } from "../auth/session"; -import { getMatchingACL, isAuthorized } from "../firewall"; +import { getFreshSession, getSession } from "../auth/session"; +import { allowsCachedSession, getMatchingACL, isAuthorized } from "../firewall"; import { isSameHost } from "../utils/requests"; +import type { Session } from "../auth/types"; import type { APIContext } from "astro"; export type FirewallResult = @@ -10,7 +11,20 @@ export type FirewallResult = | { type: "forbidden" } | { type: "basic-auth"; response: Response }; -export async function checkAuth(context: APIContext): Promise { +/** + * Load the session for this request, re-fetching the User from the API unless the path + * is listed in `CACHED_SESSION_PATHS`. + */ +export async function resolveSession(context: APIContext): Promise { + return allowsCachedSession(context.url.pathname) + ? await getSession(context.cookies) + : await getFreshSession(context.cookies); +} + +export async function checkAuth( + context: APIContext, + session: Session | undefined, +): Promise { const exemptAuth = withAuthExemption(context); if (exemptAuth) { return exemptAuth; @@ -21,7 +35,7 @@ export async function checkAuth(context: APIContext): Promise { return basicAuth; } - const aclAuth = await withACL(context); + const aclAuth = withACL(context, session); if (aclAuth) { return aclAuth; } @@ -84,15 +98,13 @@ export function withBasicAuth(context: APIContext): FirewallResult | null { return null; } -export async function withACL(context: APIContext): Promise { +export function withACL(context: APIContext, session: Session | undefined): FirewallResult | null { const acl = getMatchingACL(context.url.pathname); if (!acl) { return null; } - const session = await getSession(context.cookies); - if (!session || session.user.roles === undefined) { return { type: "unauthorized" }; } diff --git a/src/middleware/index.ts b/src/middleware/index.ts index 6634e3f7..860d3950 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -1,8 +1,7 @@ import { defineMiddleware } from "astro:middleware"; -import { checkAuth } from "./firewall"; +import { checkAuth, resolveSession } from "./firewall"; import { getLanguage, getUserLangPreferences, persistLanguage } from "./utils"; -import { getSession } from "../auth/session"; import { useTranslations } from "../i18n/utils"; import { goto } from "../utils/navigation"; @@ -10,7 +9,9 @@ import type { Locale } from "../i18n/locales/index"; import type { APIContext } from "astro"; export const onRequest = defineMiddleware(async (context: APIContext, next) => { - const auth = await checkAuth(context); + const session = await resolveSession(context); + + const auth = await checkAuth(context, session); switch (auth.type) { case "basic-auth": return auth.response; @@ -35,7 +36,7 @@ export const onRequest = defineMiddleware(async (context: APIContext, next) => { context.locals.lang = lang; context.locals.langs = langs; context.locals.t = useTranslations(lang); - context.locals.session = await getSession(context.cookies); + context.locals.session = session; } catch (e) { if (e instanceof Response) { return e; diff --git a/src/mocks/projectReviews.json b/src/mocks/projectReviews.json new file mode 100644 index 00000000..62dbac8e --- /dev/null +++ b/src/mocks/projectReviews.json @@ -0,0 +1,120 @@ +{ + "projects": { + "/v4/projects/1": { + "title": "Proyecto de ejemplo para revisar", + "status": "in_campaign_review", + "owner": "/v4/users/1" + } + }, + "reviews": [ + { + "id": 1, + "project": "/v4/projects/1", + "reviewer": "/v4/users/94182", + "type": "campaign", + "areas": [ + { + "id": 1, + "review": "/v4/reviews/1", + "title": "Área de riesgo 1", + "summary": "Resumen provisional del área de riesgo 1. Texto de ejemplo para validar el diseño de la card y su legibilidad con el resumen completo.", + "risk": "low", + "comments": [ + { + "id": 1, + "author": "/v4/users/94182", + "body": "Mensaje de ejemplo del administrador consultor sobre el área de riesgo 1.", + "area": "/v4/reviews/1/areas/1", + "dateCreated": "2026-09-14T09:12:00+00:00", + "dateUpdated": "2026-09-14T09:12:00+00:00" + }, + { + "id": 2, + "author": "/v4/users/1", + "body": "Respuesta de ejemplo del impulsor al mensaje anterior.", + "area": "/v4/reviews/1/areas/1", + "dateCreated": "2026-09-14T11:40:00+00:00", + "dateUpdated": "2026-09-14T11:40:00+00:00" + } + ] + }, + { + "id": 2, + "review": "/v4/reviews/1", + "title": "Área de riesgo 2", + "summary": "Resumen provisional del área de riesgo 2. Texto de ejemplo para validar el diseño de la card y su legibilidad con el resumen completo.", + "risk": "medium", + "comments": [ + { + "id": 3, + "author": "/v4/users/94182", + "body": "Mensaje de ejemplo del administrador consultor sobre el área de riesgo 2.", + "area": "/v4/reviews/1/areas/2", + "dateCreated": "2026-09-15T08:05:00+00:00", + "dateUpdated": "2026-09-15T08:05:00+00:00" + }, + { + "id": 4, + "author": "/v4/users/94182", + "body": "Segundo mensaje de ejemplo del administrador consultor, para comprobar el agrupado de la conversación.", + "area": "/v4/reviews/1/areas/2", + "dateCreated": "2026-09-15T08:09:00+00:00", + "dateUpdated": "2026-09-15T08:09:00+00:00" + } + ] + }, + { + "id": 3, + "review": "/v4/reviews/1", + "title": "Área de riesgo 3", + "summary": "Resumen provisional del área de riesgo 3. Texto de ejemplo para validar el diseño de la card y su legibilidad con el resumen completo.", + "risk": "high", + "comments": [ + { + "id": 5, + "author": "/v4/users/94182", + "body": "Mensaje de ejemplo del administrador consultor sobre el área de riesgo 3.", + "area": "/v4/reviews/1/areas/3", + "dateCreated": "2026-09-16T16:30:00+00:00", + "dateUpdated": "2026-09-16T16:30:00+00:00" + }, + { + "id": 6, + "author": "/v4/users/1", + "body": "Respuesta de ejemplo del impulsor, con un texto algo más largo para comprobar cómo se comporta el mensaje cuando ocupa varias líneas dentro de la burbuja del chat.", + "area": "/v4/reviews/1/areas/3", + "dateCreated": "2026-09-17T07:20:00+00:00", + "dateUpdated": "2026-09-17T07:20:00+00:00" + }, + { + "id": 7, + "author": "/v4/users/94182", + "body": "Cierre de ejemplo del administrador consultor.", + "area": "/v4/reviews/1/areas/3", + "dateCreated": "2026-09-17T10:02:00+00:00", + "dateUpdated": "2026-09-17T10:02:00+00:00" + } + ] + }, + { + "id": 4, + "review": "/v4/reviews/1", + "title": "Área de riesgo 4", + "summary": "Resumen provisional del área de riesgo 4. Todavía sin evaluar, para comprobar el estado inicial de la card cuando el riesgo es nulo.", + "risk": null, + "comments": [] + } + ] + } + ], + "authors": { + "/v4/users/1": { + "handle": "impulsor", + "displayName": "Impulsor del proyecto" + }, + "/v4/users/94182": { + "handle": "consultora", + "displayName": "Administrador consultor" + } + } +} diff --git a/src/openapi/client/index.ts b/src/openapi/client/index.ts index a2ceec7c..5dd281d4 100644 --- a/src/openapi/client/index.ts +++ b/src/openapi/client/index.ts @@ -1,5 +1,5 @@ // This file is auto-generated by @hey-api/openapi-ts -export { apiAccountingBalancePointsGetCollectionUrl, apiAccountingsGetCollectionUrl, apiAccountingsIdGetUrl, apiAccountingTransactionsGetCollectionUrl, apiAccountingTransactionsIdGetUrl, apiCategoriesGetCollectionUrl, apiCategoriesIdDeleteUrl, apiCategoriesIdOrSlugGetUrl, apiCategoriesIdPatchUrl, apiCategoriesPostUrl, apiGatewayChargesGetCollectionUrl, apiGatewayChargesIdGetUrl, apiGatewayChargesIdPatchUrl, apiGatewayChargestotalsGetCollectionUrl, apiGatewayCheckoutsGetCollectionUrl, apiGatewayCheckoutsIdGetUrl, apiGatewayCheckoutsIdPatchUrl, apiGatewayCheckoutsPostUrl, apiGatewaysGetCollectionUrl, apiGatewaysIdGetUrl, apiMatchCallsGetCollectionUrl, apiMatchCallsIdDeleteUrl, apiMatchCallsIdGetUrl, apiMatchCallsIdPatchUrl, apiMatchCallsPostUrl, apiMatchCallSubmissionsGetCollectionUrl, apiMatchCallSubmissionsIdGetUrl, apiMatchCallSubmissionsIdPatchUrl, apiMatchCallSubmissionsPostUrl, apiMatchFormulasGetCollectionUrl, apiMatchFormulasNameGetUrl, apiMatchRulesGetCollectionUrl, apiMatchRulesNameGetUrl, apiMatchStrategiesGetCollectionUrl, apiMatchStrategiesIdDeleteUrl, apiMatchStrategiesIdGetUrl, apiMatchStrategiesIdPatchUrl, apiMatchStrategiesPostUrl, apiPingGetUrl, apiProjectBudgetItemsGetCollectionUrl, apiProjectBudgetItemsIdDeleteUrl, apiProjectBudgetItemsIdGetUrl, apiProjectBudgetItemsIdPatchUrl, apiProjectBudgetItemsPostUrl, apiProjectCollaborationCandidaciesGetCollectionUrl, apiProjectCollaborationCandidaciesIdDeleteUrl, apiProjectCollaborationCandidaciesIdGetUrl, apiProjectCollaborationCandidaciesIdPatchUrl, apiProjectCollaborationCandidaciesPostUrl, apiProjectCollaborationsGetCollectionUrl, apiProjectCollaborationsIdDeleteUrl, apiProjectCollaborationsIdGetUrl, apiProjectCollaborationsIdPatchUrl, apiProjectCollaborationsPostUrl, apiProjectRewardClaimsGetCollectionUrl, apiProjectRewardClaimsIdDeleteUrl, apiProjectRewardClaimsIdGetUrl, apiProjectRewardClaimsPostUrl, apiProjectRewardsGetCollectionUrl, apiProjectRewardsIdDeleteUrl, apiProjectRewardsIdGetUrl, apiProjectRewardsIdPatchUrl, apiProjectRewardsPostUrl, apiProjectsGetCollectionUrl, apiProjectsIdDeleteUrl, apiProjectsIdOrSlugGetUrl, apiProjectsIdPatchUrl, apiProjectsPostUrl, apiProjectSupportsGetCollectionUrl, apiProjectSupportsIdGetUrl, apiProjectSupportsIdPatchUrl, apiProjectSupportsmoneyTotalGetCollectionUrl, apiProjectUpdatesGetCollectionUrl, apiProjectUpdatesIdDeleteUrl, apiProjectUpdatesIdGetUrl, apiProjectUpdatesIdPatchUrl, apiProjectUpdatesPostUrl, apiTipjarsGetCollectionUrl, apiTipjarsIdDeleteUrl, apiTipjarsIdGetUrl, apiTipjarsIdPatchUrl, apiTipjarsPostUrl, apiUsersGetCollectionUrl, apiUsersIdDeleteUrl, apiUsersIdorganizationGetUrl, apiUsersIdorganizationPatchUrl, apiUsersIdOrHandleGetUrl, apiUsersIdPatchUrl, apiUsersIdpersonGetUrl, apiUsersIdpersonPatchUrl, apiUsersPostUrl, apiVersionsGetCollectionUrl, apiVersionsIdGetUrl } from './operation-paths.gen'; -export { apiAccountingBalancePointsGetCollection, apiAccountingsGetCollection, apiAccountingsIdGet, apiAccountingTransactionsGetCollection, apiAccountingTransactionsIdGet, apiCategoriesGetCollection, apiCategoriesIdDelete, apiCategoriesIdOrSlugGet, apiCategoriesIdPatch, apiCategoriesPost, apiGatewayChargesGetCollection, apiGatewayChargesIdGet, apiGatewayChargesIdPatch, apiGatewayChargestotalsGetCollection, apiGatewayCheckoutsGetCollection, apiGatewayCheckoutsIdGet, apiGatewayCheckoutsIdPatch, apiGatewayCheckoutsPost, apiGatewaysGetCollection, apiGatewaysIdGet, apiMatchCallsGetCollection, apiMatchCallsIdDelete, apiMatchCallsIdGet, apiMatchCallsIdPatch, apiMatchCallsPost, apiMatchCallSubmissionsGetCollection, apiMatchCallSubmissionsIdGet, apiMatchCallSubmissionsIdPatch, apiMatchCallSubmissionsPost, apiMatchFormulasGetCollection, apiMatchFormulasNameGet, apiMatchRulesGetCollection, apiMatchRulesNameGet, apiMatchStrategiesGetCollection, apiMatchStrategiesIdDelete, apiMatchStrategiesIdGet, apiMatchStrategiesIdPatch, apiMatchStrategiesPost, apiPingGet, apiProjectBudgetItemsGetCollection, apiProjectBudgetItemsIdDelete, apiProjectBudgetItemsIdGet, apiProjectBudgetItemsIdPatch, apiProjectBudgetItemsPost, apiProjectCollaborationCandidaciesGetCollection, apiProjectCollaborationCandidaciesIdDelete, apiProjectCollaborationCandidaciesIdGet, apiProjectCollaborationCandidaciesIdPatch, apiProjectCollaborationCandidaciesPost, apiProjectCollaborationsGetCollection, apiProjectCollaborationsIdDelete, apiProjectCollaborationsIdGet, apiProjectCollaborationsIdPatch, apiProjectCollaborationsPost, apiProjectRewardClaimsGetCollection, apiProjectRewardClaimsIdDelete, apiProjectRewardClaimsIdGet, apiProjectRewardClaimsPost, apiProjectRewardsGetCollection, apiProjectRewardsIdDelete, apiProjectRewardsIdGet, apiProjectRewardsIdPatch, apiProjectRewardsPost, apiProjectsGetCollection, apiProjectsIdDelete, apiProjectsIdOrSlugGet, apiProjectsIdPatch, apiProjectsPost, apiProjectSupportsGetCollection, apiProjectSupportsIdGet, apiProjectSupportsIdPatch, apiProjectSupportsmoneyTotalGetCollection, apiProjectUpdatesGetCollection, apiProjectUpdatesIdDelete, apiProjectUpdatesIdGet, apiProjectUpdatesIdPatch, apiProjectUpdatesPost, apiTipjarsGetCollection, apiTipjarsIdDelete, apiTipjarsIdGet, apiTipjarsIdPatch, apiTipjarsPost, apiUsersGetCollection, apiUsersIdDelete, apiUsersIdorganizationGet, apiUsersIdorganizationPatch, apiUsersIdOrHandleGet, apiUsersIdPatch, apiUsersIdpersonGet, apiUsersIdpersonPatch, apiUsersPost, apiVersionsGetCollection, apiVersionsIdGet, type Options } from './sdk.gen'; -export type { Accounting, AccountingBalancePoint, AccountingBalancePointJsonld, AccountingJsonld, AccountingTransaction, AccountingTransactionJsonld, ApiAccountingBalancePointsGetCollectionData, ApiAccountingBalancePointsGetCollectionResponse, ApiAccountingBalancePointsGetCollectionResponses, ApiAccountingsGetCollectionData, ApiAccountingsGetCollectionResponse, ApiAccountingsGetCollectionResponses, ApiAccountingsIdGetData, ApiAccountingsIdGetError, ApiAccountingsIdGetErrors, ApiAccountingsIdGetResponse, ApiAccountingsIdGetResponses, ApiAccountingTransactionsGetCollectionData, ApiAccountingTransactionsGetCollectionResponse, ApiAccountingTransactionsGetCollectionResponses, ApiAccountingTransactionsIdGetData, ApiAccountingTransactionsIdGetError, ApiAccountingTransactionsIdGetErrors, ApiAccountingTransactionsIdGetResponse, ApiAccountingTransactionsIdGetResponses, ApiCategoriesGetCollectionData, ApiCategoriesGetCollectionResponse, ApiCategoriesGetCollectionResponses, ApiCategoriesIdDeleteData, ApiCategoriesIdDeleteError, ApiCategoriesIdDeleteErrors, ApiCategoriesIdDeleteResponse, ApiCategoriesIdDeleteResponses, ApiCategoriesIdOrSlugGetData, ApiCategoriesIdOrSlugGetError, ApiCategoriesIdOrSlugGetErrors, ApiCategoriesIdOrSlugGetResponse, ApiCategoriesIdOrSlugGetResponses, ApiCategoriesIdPatchData, ApiCategoriesIdPatchError, ApiCategoriesIdPatchErrors, ApiCategoriesIdPatchResponse, ApiCategoriesIdPatchResponses, ApiCategoriesPostData, ApiCategoriesPostError, ApiCategoriesPostErrors, ApiCategoriesPostResponse, ApiCategoriesPostResponses, ApiGatewayChargesGetCollectionData, ApiGatewayChargesGetCollectionResponse, ApiGatewayChargesGetCollectionResponses, ApiGatewayChargesIdGetData, ApiGatewayChargesIdGetError, ApiGatewayChargesIdGetErrors, ApiGatewayChargesIdGetResponse, ApiGatewayChargesIdGetResponses, ApiGatewayChargesIdPatchData, ApiGatewayChargesIdPatchError, ApiGatewayChargesIdPatchErrors, ApiGatewayChargesIdPatchResponse, ApiGatewayChargesIdPatchResponses, ApiGatewayChargestotalsGetCollectionData, ApiGatewayChargestotalsGetCollectionResponse, ApiGatewayChargestotalsGetCollectionResponses, ApiGatewayCheckoutsGetCollectionData, ApiGatewayCheckoutsGetCollectionResponse, ApiGatewayCheckoutsGetCollectionResponses, ApiGatewayCheckoutsIdGetData, ApiGatewayCheckoutsIdGetError, ApiGatewayCheckoutsIdGetErrors, ApiGatewayCheckoutsIdGetResponse, ApiGatewayCheckoutsIdGetResponses, ApiGatewayCheckoutsIdPatchData, ApiGatewayCheckoutsIdPatchError, ApiGatewayCheckoutsIdPatchErrors, ApiGatewayCheckoutsIdPatchResponse, ApiGatewayCheckoutsIdPatchResponses, ApiGatewayCheckoutsPostData, ApiGatewayCheckoutsPostError, ApiGatewayCheckoutsPostErrors, ApiGatewayCheckoutsPostResponse, ApiGatewayCheckoutsPostResponses, ApiGatewaysGetCollectionData, ApiGatewaysGetCollectionResponse, ApiGatewaysGetCollectionResponses, ApiGatewaysIdGetData, ApiGatewaysIdGetError, ApiGatewaysIdGetErrors, ApiGatewaysIdGetResponse, ApiGatewaysIdGetResponses, ApiMatchCallsGetCollectionData, ApiMatchCallsGetCollectionResponse, ApiMatchCallsGetCollectionResponses, ApiMatchCallsIdDeleteData, ApiMatchCallsIdDeleteError, ApiMatchCallsIdDeleteErrors, ApiMatchCallsIdDeleteResponse, ApiMatchCallsIdDeleteResponses, ApiMatchCallsIdGetData, ApiMatchCallsIdGetError, ApiMatchCallsIdGetErrors, ApiMatchCallsIdGetResponse, ApiMatchCallsIdGetResponses, ApiMatchCallsIdPatchData, ApiMatchCallsIdPatchError, ApiMatchCallsIdPatchErrors, ApiMatchCallsIdPatchResponse, ApiMatchCallsIdPatchResponses, ApiMatchCallsPostData, ApiMatchCallsPostError, ApiMatchCallsPostErrors, ApiMatchCallsPostResponse, ApiMatchCallsPostResponses, ApiMatchCallSubmissionsGetCollectionData, ApiMatchCallSubmissionsGetCollectionResponse, ApiMatchCallSubmissionsGetCollectionResponses, ApiMatchCallSubmissionsIdGetData, ApiMatchCallSubmissionsIdGetError, ApiMatchCallSubmissionsIdGetErrors, ApiMatchCallSubmissionsIdGetResponse, ApiMatchCallSubmissionsIdGetResponses, ApiMatchCallSubmissionsIdPatchData, ApiMatchCallSubmissionsIdPatchError, ApiMatchCallSubmissionsIdPatchErrors, ApiMatchCallSubmissionsIdPatchResponse, ApiMatchCallSubmissionsIdPatchResponses, ApiMatchCallSubmissionsPostData, ApiMatchCallSubmissionsPostError, ApiMatchCallSubmissionsPostErrors, ApiMatchCallSubmissionsPostResponse, ApiMatchCallSubmissionsPostResponses, ApiMatchFormulasGetCollectionData, ApiMatchFormulasGetCollectionResponse, ApiMatchFormulasGetCollectionResponses, ApiMatchFormulasNameGetData, ApiMatchFormulasNameGetError, ApiMatchFormulasNameGetErrors, ApiMatchFormulasNameGetResponse, ApiMatchFormulasNameGetResponses, ApiMatchRulesGetCollectionData, ApiMatchRulesGetCollectionResponse, ApiMatchRulesGetCollectionResponses, ApiMatchRulesNameGetData, ApiMatchRulesNameGetError, ApiMatchRulesNameGetErrors, ApiMatchRulesNameGetResponse, ApiMatchRulesNameGetResponses, ApiMatchStrategiesGetCollectionData, ApiMatchStrategiesGetCollectionResponse, ApiMatchStrategiesGetCollectionResponses, ApiMatchStrategiesIdDeleteData, ApiMatchStrategiesIdDeleteError, ApiMatchStrategiesIdDeleteErrors, ApiMatchStrategiesIdDeleteResponse, ApiMatchStrategiesIdDeleteResponses, ApiMatchStrategiesIdGetData, ApiMatchStrategiesIdGetError, ApiMatchStrategiesIdGetErrors, ApiMatchStrategiesIdGetResponse, ApiMatchStrategiesIdGetResponses, ApiMatchStrategiesIdPatchData, ApiMatchStrategiesIdPatchError, ApiMatchStrategiesIdPatchErrors, ApiMatchStrategiesIdPatchResponse, ApiMatchStrategiesIdPatchResponses, ApiMatchStrategiesPostData, ApiMatchStrategiesPostError, ApiMatchStrategiesPostErrors, ApiMatchStrategiesPostResponse, ApiMatchStrategiesPostResponses, ApiPingGetData, ApiPingGetError, ApiPingGetErrors, ApiPingGetResponse, ApiPingGetResponses, ApiProjectBudgetItemsGetCollectionData, ApiProjectBudgetItemsGetCollectionResponse, ApiProjectBudgetItemsGetCollectionResponses, ApiProjectBudgetItemsIdDeleteData, ApiProjectBudgetItemsIdDeleteError, ApiProjectBudgetItemsIdDeleteErrors, ApiProjectBudgetItemsIdDeleteResponse, ApiProjectBudgetItemsIdDeleteResponses, ApiProjectBudgetItemsIdGetData, ApiProjectBudgetItemsIdGetError, ApiProjectBudgetItemsIdGetErrors, ApiProjectBudgetItemsIdGetResponse, ApiProjectBudgetItemsIdGetResponses, ApiProjectBudgetItemsIdPatchData, ApiProjectBudgetItemsIdPatchError, ApiProjectBudgetItemsIdPatchErrors, ApiProjectBudgetItemsIdPatchResponse, ApiProjectBudgetItemsIdPatchResponses, ApiProjectBudgetItemsPostData, ApiProjectBudgetItemsPostError, ApiProjectBudgetItemsPostErrors, ApiProjectBudgetItemsPostResponse, ApiProjectBudgetItemsPostResponses, ApiProjectCollaborationCandidaciesGetCollectionData, ApiProjectCollaborationCandidaciesGetCollectionResponse, ApiProjectCollaborationCandidaciesGetCollectionResponses, ApiProjectCollaborationCandidaciesIdDeleteData, ApiProjectCollaborationCandidaciesIdDeleteError, ApiProjectCollaborationCandidaciesIdDeleteErrors, ApiProjectCollaborationCandidaciesIdDeleteResponse, ApiProjectCollaborationCandidaciesIdDeleteResponses, ApiProjectCollaborationCandidaciesIdGetData, ApiProjectCollaborationCandidaciesIdGetError, ApiProjectCollaborationCandidaciesIdGetErrors, ApiProjectCollaborationCandidaciesIdGetResponse, ApiProjectCollaborationCandidaciesIdGetResponses, ApiProjectCollaborationCandidaciesIdPatchData, ApiProjectCollaborationCandidaciesIdPatchError, ApiProjectCollaborationCandidaciesIdPatchErrors, ApiProjectCollaborationCandidaciesIdPatchResponse, ApiProjectCollaborationCandidaciesIdPatchResponses, ApiProjectCollaborationCandidaciesPostData, ApiProjectCollaborationCandidaciesPostError, ApiProjectCollaborationCandidaciesPostErrors, ApiProjectCollaborationCandidaciesPostResponse, ApiProjectCollaborationCandidaciesPostResponses, ApiProjectCollaborationsGetCollectionData, ApiProjectCollaborationsGetCollectionResponse, ApiProjectCollaborationsGetCollectionResponses, ApiProjectCollaborationsIdDeleteData, ApiProjectCollaborationsIdDeleteError, ApiProjectCollaborationsIdDeleteErrors, ApiProjectCollaborationsIdDeleteResponse, ApiProjectCollaborationsIdDeleteResponses, ApiProjectCollaborationsIdGetData, ApiProjectCollaborationsIdGetError, ApiProjectCollaborationsIdGetErrors, ApiProjectCollaborationsIdGetResponse, ApiProjectCollaborationsIdGetResponses, ApiProjectCollaborationsIdPatchData, ApiProjectCollaborationsIdPatchError, ApiProjectCollaborationsIdPatchErrors, ApiProjectCollaborationsIdPatchResponse, ApiProjectCollaborationsIdPatchResponses, ApiProjectCollaborationsPostData, ApiProjectCollaborationsPostError, ApiProjectCollaborationsPostErrors, ApiProjectCollaborationsPostResponse, ApiProjectCollaborationsPostResponses, ApiProjectRewardClaimsGetCollectionData, ApiProjectRewardClaimsGetCollectionResponse, ApiProjectRewardClaimsGetCollectionResponses, ApiProjectRewardClaimsIdDeleteData, ApiProjectRewardClaimsIdDeleteError, ApiProjectRewardClaimsIdDeleteErrors, ApiProjectRewardClaimsIdDeleteResponse, ApiProjectRewardClaimsIdDeleteResponses, ApiProjectRewardClaimsIdGetData, ApiProjectRewardClaimsIdGetError, ApiProjectRewardClaimsIdGetErrors, ApiProjectRewardClaimsIdGetResponse, ApiProjectRewardClaimsIdGetResponses, ApiProjectRewardClaimsPostData, ApiProjectRewardClaimsPostError, ApiProjectRewardClaimsPostErrors, ApiProjectRewardClaimsPostResponse, ApiProjectRewardClaimsPostResponses, ApiProjectRewardsGetCollectionData, ApiProjectRewardsGetCollectionResponse, ApiProjectRewardsGetCollectionResponses, ApiProjectRewardsIdDeleteData, ApiProjectRewardsIdDeleteError, ApiProjectRewardsIdDeleteErrors, ApiProjectRewardsIdDeleteResponse, ApiProjectRewardsIdDeleteResponses, ApiProjectRewardsIdGetData, ApiProjectRewardsIdGetError, ApiProjectRewardsIdGetErrors, ApiProjectRewardsIdGetResponse, ApiProjectRewardsIdGetResponses, ApiProjectRewardsIdPatchData, ApiProjectRewardsIdPatchError, ApiProjectRewardsIdPatchErrors, ApiProjectRewardsIdPatchResponse, ApiProjectRewardsIdPatchResponses, ApiProjectRewardsPostData, ApiProjectRewardsPostError, ApiProjectRewardsPostErrors, ApiProjectRewardsPostResponse, ApiProjectRewardsPostResponses, ApiProjectsGetCollectionData, ApiProjectsGetCollectionResponse, ApiProjectsGetCollectionResponses, ApiProjectsIdDeleteData, ApiProjectsIdDeleteError, ApiProjectsIdDeleteErrors, ApiProjectsIdDeleteResponse, ApiProjectsIdDeleteResponses, ApiProjectsIdOrSlugGetData, ApiProjectsIdOrSlugGetError, ApiProjectsIdOrSlugGetErrors, ApiProjectsIdOrSlugGetResponse, ApiProjectsIdOrSlugGetResponses, ApiProjectsIdPatchData, ApiProjectsIdPatchError, ApiProjectsIdPatchErrors, ApiProjectsIdPatchResponse, ApiProjectsIdPatchResponses, ApiProjectsPostData, ApiProjectsPostError, ApiProjectsPostErrors, ApiProjectsPostResponse, ApiProjectsPostResponses, ApiProjectSupportsGetCollectionData, ApiProjectSupportsGetCollectionResponse, ApiProjectSupportsGetCollectionResponses, ApiProjectSupportsIdGetData, ApiProjectSupportsIdGetError, ApiProjectSupportsIdGetErrors, ApiProjectSupportsIdGetResponse, ApiProjectSupportsIdGetResponses, ApiProjectSupportsIdPatchData, ApiProjectSupportsIdPatchError, ApiProjectSupportsIdPatchErrors, ApiProjectSupportsIdPatchResponse, ApiProjectSupportsIdPatchResponses, ApiProjectSupportsmoneyTotalGetCollectionData, ApiProjectSupportsmoneyTotalGetCollectionResponse, ApiProjectSupportsmoneyTotalGetCollectionResponses, ApiProjectUpdatesGetCollectionData, ApiProjectUpdatesGetCollectionResponse, ApiProjectUpdatesGetCollectionResponses, ApiProjectUpdatesIdDeleteData, ApiProjectUpdatesIdDeleteError, ApiProjectUpdatesIdDeleteErrors, ApiProjectUpdatesIdDeleteResponse, ApiProjectUpdatesIdDeleteResponses, ApiProjectUpdatesIdGetData, ApiProjectUpdatesIdGetError, ApiProjectUpdatesIdGetErrors, ApiProjectUpdatesIdGetResponse, ApiProjectUpdatesIdGetResponses, ApiProjectUpdatesIdPatchData, ApiProjectUpdatesIdPatchError, ApiProjectUpdatesIdPatchErrors, ApiProjectUpdatesIdPatchResponse, ApiProjectUpdatesIdPatchResponses, ApiProjectUpdatesPostData, ApiProjectUpdatesPostError, ApiProjectUpdatesPostErrors, ApiProjectUpdatesPostResponse, ApiProjectUpdatesPostResponses, ApiTipjarsGetCollectionData, ApiTipjarsGetCollectionResponse, ApiTipjarsGetCollectionResponses, ApiTipjarsIdDeleteData, ApiTipjarsIdDeleteError, ApiTipjarsIdDeleteErrors, ApiTipjarsIdDeleteResponse, ApiTipjarsIdDeleteResponses, ApiTipjarsIdGetData, ApiTipjarsIdGetError, ApiTipjarsIdGetErrors, ApiTipjarsIdGetResponse, ApiTipjarsIdGetResponses, ApiTipjarsIdPatchData, ApiTipjarsIdPatchError, ApiTipjarsIdPatchErrors, ApiTipjarsIdPatchResponse, ApiTipjarsIdPatchResponses, ApiTipjarsPostData, ApiTipjarsPostError, ApiTipjarsPostErrors, ApiTipjarsPostResponse, ApiTipjarsPostResponses, ApiUsersGetCollectionData, ApiUsersGetCollectionResponse, ApiUsersGetCollectionResponses, ApiUsersIdDeleteData, ApiUsersIdDeleteError, ApiUsersIdDeleteErrors, ApiUsersIdDeleteResponse, ApiUsersIdDeleteResponses, ApiUsersIdorganizationGetData, ApiUsersIdorganizationGetError, ApiUsersIdorganizationGetErrors, ApiUsersIdorganizationGetResponse, ApiUsersIdorganizationGetResponses, ApiUsersIdorganizationPatchData, ApiUsersIdorganizationPatchError, ApiUsersIdorganizationPatchErrors, ApiUsersIdorganizationPatchResponse, ApiUsersIdorganizationPatchResponses, ApiUsersIdOrHandleGetData, ApiUsersIdOrHandleGetError, ApiUsersIdOrHandleGetErrors, ApiUsersIdOrHandleGetResponse, ApiUsersIdOrHandleGetResponses, ApiUsersIdPatchData, ApiUsersIdPatchError, ApiUsersIdPatchErrors, ApiUsersIdPatchResponse, ApiUsersIdPatchResponses, ApiUsersIdpersonGetData, ApiUsersIdpersonGetError, ApiUsersIdpersonGetErrors, ApiUsersIdpersonGetResponse, ApiUsersIdpersonGetResponses, ApiUsersIdpersonPatchData, ApiUsersIdpersonPatchError, ApiUsersIdpersonPatchErrors, ApiUsersIdpersonPatchResponse, ApiUsersIdpersonPatchResponses, ApiUsersPostData, ApiUsersPostError, ApiUsersPostErrors, ApiUsersPostResponse, ApiUsersPostResponses, ApiVersionsGetCollectionData, ApiVersionsGetCollectionResponse, ApiVersionsGetCollectionResponses, ApiVersionsIdGetData, ApiVersionsIdGetError, ApiVersionsIdGetErrors, ApiVersionsIdGetResponse, ApiVersionsIdGetResponses, Budget, BudgetJsonld, BudgetSummary, BudgetSummaryJsonld, Category, CategoryJsonld, ChargeCreationDto, ChargeCreationDtoJsonld, ClientOptions, ConstraintViolationJson, ConstraintViolationJsonldJsonld, Conversion, ConversionJsonld, Error, ErrorJsonld, Gateway, GatewayCharge, GatewayChargeChargesTotalsDto, GatewayChargeChargesTotalsDtoJsonld, GatewayChargeChargeUpdationDto, GatewayChargeJsonld, GatewayCheckout, GatewayCheckoutCheckoutCreationDto, GatewayCheckoutCheckoutCreationDtoJsonld, GatewayCheckoutCheckoutUpdationDto, GatewayCheckoutJsonld, GatewayJsonld, GatewayLink, GatewayLinkJsonld, Link, LinkJsonld, MatchCall, MatchCallJsonld, MatchCallSubmission, MatchCallSubmissionJsonld, MatchCallSubmissionMatchCallSubmissionCreationDto, MatchCallSubmissionMatchCallSubmissionCreationDtoJsonld, MatchFormula, MatchFormulaJsonld, MatchRule, MatchRuleJsonld, MatchStrategy, MatchStrategyJsonld, Money, MoneyInput, MoneyInputJsonld, MoneyJsonld, MoneyOutput, MoneyOutputJsonld, Organization, OrganizationJsonld, Person, PersonJsonld, Ping, PingJsonld, Project, ProjectBudgetItem, ProjectBudgetItemJsonld, ProjectCalendar, ProjectCalendarJsonld, ProjectCollaboration, ProjectCollaborationCandidacy, ProjectCollaborationCandidacyJsonld, ProjectCollaborationJsonld, ProjectJsonld, ProjectProjectCreationDto, ProjectProjectCreationDtoJsonld, ProjectProjectUpdationDto, ProjectReward, ProjectRewardClaim, ProjectRewardClaimJsonld, ProjectRewardClaimRewardClaimCreationDto, ProjectRewardClaimRewardClaimCreationDtoJsonld, ProjectRewardJsonld, ProjectSupport, ProjectSupportJsonld, ProjectSupportTotalizedMoney, ProjectSupportTotalizedMoneyJsonld, ProjectUpdate, ProjectUpdateJsonld, ProjectVideo, ProjectVideoJsonld, Territory, TerritoryJsonld, Tipjar, TipjarJsonld, TotalizedMoney, TotalizedMoneyJsonld, Tracking, TrackingJsonld, User, UserJsonld, UserUserSignupDto, UserUserSignupDtoJsonld, Version, VersionJsonld } from './types.gen'; +export { apiAccountingBalancePointsGetCollectionUrl, apiAccountingsGetCollectionUrl, apiAccountingsIdGetUrl, apiAccountingTransactionsGetCollectionUrl, apiAccountingTransactionsIdGetUrl, apiCategoriesGetCollectionUrl, apiCategoriesIdDeleteUrl, apiCategoriesIdOrSlugGetUrl, apiCategoriesIdPatchUrl, apiCategoriesPostUrl, apiGatewayChargesGetCollectionUrl, apiGatewayChargesIdGetUrl, apiGatewayChargesIdPatchUrl, apiGatewayChargestotalsGetCollectionUrl, apiGatewayCheckoutsGetCollectionUrl, apiGatewayCheckoutsIdGetUrl, apiGatewayCheckoutsIdPatchUrl, apiGatewayCheckoutsPostUrl, apiGatewaysGetCollectionUrl, apiGatewaysIdGetUrl, apiMatchCallsGetCollectionUrl, apiMatchCallsIdDeleteUrl, apiMatchCallsIdGetUrl, apiMatchCallsIdPatchUrl, apiMatchCallsPostUrl, apiMatchCallSubmissionsGetCollectionUrl, apiMatchCallSubmissionsIdGetUrl, apiMatchCallSubmissionsIdPatchUrl, apiMatchCallSubmissionsPostUrl, apiMatchFormulasGetCollectionUrl, apiMatchFormulasNameGetUrl, apiMatchRulesGetCollectionUrl, apiMatchRulesNameGetUrl, apiMatchStrategiesGetCollectionUrl, apiMatchStrategiesIdDeleteUrl, apiMatchStrategiesIdGetUrl, apiMatchStrategiesIdPatchUrl, apiMatchStrategiesPostUrl, apiPingGetUrl, apiProjectBudgetItemsGetCollectionUrl, apiProjectBudgetItemsIdDeleteUrl, apiProjectBudgetItemsIdGetUrl, apiProjectBudgetItemsIdPatchUrl, apiProjectBudgetItemsPostUrl, apiProjectCollaborationCandidaciesGetCollectionUrl, apiProjectCollaborationCandidaciesIdDeleteUrl, apiProjectCollaborationCandidaciesIdGetUrl, apiProjectCollaborationCandidaciesIdPatchUrl, apiProjectCollaborationCandidaciesPostUrl, apiProjectCollaborationsGetCollectionUrl, apiProjectCollaborationsIdDeleteUrl, apiProjectCollaborationsIdGetUrl, apiProjectCollaborationsIdPatchUrl, apiProjectCollaborationsPostUrl, apiProjectRewardClaimsGetCollectionUrl, apiProjectRewardClaimsIdDeleteUrl, apiProjectRewardClaimsIdGetUrl, apiProjectRewardClaimsIdPatchUrl, apiProjectRewardClaimsPostUrl, apiProjectRewardsGetCollectionUrl, apiProjectRewardsIdDeleteUrl, apiProjectRewardsIdGetUrl, apiProjectRewardsIdPatchUrl, apiProjectRewardsPostUrl, apiProjectsGetCollectionUrl, apiProjectsIdDeleteUrl, apiProjectsIdOrSlugGetUrl, apiProjectsIdPatchUrl, apiProjectsPostUrl, apiProjectSupportsGetCollectionUrl, apiProjectSupportsIdGetUrl, apiProjectSupportsIdPatchUrl, apiProjectSupportsmoneyTotalGetCollectionUrl, apiProjectUpdatesGetCollectionUrl, apiProjectUpdatesIdDeleteUrl, apiProjectUpdatesIdGetUrl, apiProjectUpdatesIdPatchUrl, apiProjectUpdatesPostUrl, apiTipjarsGetCollectionUrl, apiTipjarsIdDeleteUrl, apiTipjarsIdGetUrl, apiTipjarsIdPatchUrl, apiTipjarsPostUrl, apiUsersGetCollectionUrl, apiUsersIdDeleteUrl, apiUsersIdorganizationGetUrl, apiUsersIdorganizationPatchUrl, apiUsersIdOrHandleGetUrl, apiUsersIdPatchUrl, apiUsersIdpersonGetUrl, apiUsersIdpersonPatchUrl, apiUsersPostUrl, apiVersionsGetCollectionUrl, apiVersionsIdGetUrl } from './operation-paths.gen'; +export { apiAccountingBalancePointsGetCollection, apiAccountingsGetCollection, apiAccountingsIdGet, apiAccountingTransactionsGetCollection, apiAccountingTransactionsIdGet, apiCategoriesGetCollection, apiCategoriesIdDelete, apiCategoriesIdOrSlugGet, apiCategoriesIdPatch, apiCategoriesPost, apiGatewayChargesGetCollection, apiGatewayChargesIdGet, apiGatewayChargesIdPatch, apiGatewayChargestotalsGetCollection, apiGatewayCheckoutsGetCollection, apiGatewayCheckoutsIdGet, apiGatewayCheckoutsIdPatch, apiGatewayCheckoutsPost, apiGatewaysGetCollection, apiGatewaysIdGet, apiMatchCallsGetCollection, apiMatchCallsIdDelete, apiMatchCallsIdGet, apiMatchCallsIdPatch, apiMatchCallsPost, apiMatchCallSubmissionsGetCollection, apiMatchCallSubmissionsIdGet, apiMatchCallSubmissionsIdPatch, apiMatchCallSubmissionsPost, apiMatchFormulasGetCollection, apiMatchFormulasNameGet, apiMatchRulesGetCollection, apiMatchRulesNameGet, apiMatchStrategiesGetCollection, apiMatchStrategiesIdDelete, apiMatchStrategiesIdGet, apiMatchStrategiesIdPatch, apiMatchStrategiesPost, apiPingGet, apiProjectBudgetItemsGetCollection, apiProjectBudgetItemsIdDelete, apiProjectBudgetItemsIdGet, apiProjectBudgetItemsIdPatch, apiProjectBudgetItemsPost, apiProjectCollaborationCandidaciesGetCollection, apiProjectCollaborationCandidaciesIdDelete, apiProjectCollaborationCandidaciesIdGet, apiProjectCollaborationCandidaciesIdPatch, apiProjectCollaborationCandidaciesPost, apiProjectCollaborationsGetCollection, apiProjectCollaborationsIdDelete, apiProjectCollaborationsIdGet, apiProjectCollaborationsIdPatch, apiProjectCollaborationsPost, apiProjectRewardClaimsGetCollection, apiProjectRewardClaimsIdDelete, apiProjectRewardClaimsIdGet, apiProjectRewardClaimsIdPatch, apiProjectRewardClaimsPost, apiProjectRewardsGetCollection, apiProjectRewardsIdDelete, apiProjectRewardsIdGet, apiProjectRewardsIdPatch, apiProjectRewardsPost, apiProjectsGetCollection, apiProjectsIdDelete, apiProjectsIdOrSlugGet, apiProjectsIdPatch, apiProjectsPost, apiProjectSupportsGetCollection, apiProjectSupportsIdGet, apiProjectSupportsIdPatch, apiProjectSupportsmoneyTotalGetCollection, apiProjectUpdatesGetCollection, apiProjectUpdatesIdDelete, apiProjectUpdatesIdGet, apiProjectUpdatesIdPatch, apiProjectUpdatesPost, apiTipjarsGetCollection, apiTipjarsIdDelete, apiTipjarsIdGet, apiTipjarsIdPatch, apiTipjarsPost, apiUsersGetCollection, apiUsersIdDelete, apiUsersIdorganizationGet, apiUsersIdorganizationPatch, apiUsersIdOrHandleGet, apiUsersIdPatch, apiUsersIdpersonGet, apiUsersIdpersonPatch, apiUsersPost, apiVersionsGetCollection, apiVersionsIdGet, type Options } from './sdk.gen'; +export type { Accounting, AccountingBalancePoint, AccountingBalancePointJsonld, AccountingJsonld, AccountingTransaction, AccountingTransactionJsonld, ApiAccountingBalancePointsGetCollectionData, ApiAccountingBalancePointsGetCollectionResponse, ApiAccountingBalancePointsGetCollectionResponses, ApiAccountingsGetCollectionData, ApiAccountingsGetCollectionResponse, ApiAccountingsGetCollectionResponses, ApiAccountingsIdGetData, ApiAccountingsIdGetError, ApiAccountingsIdGetErrors, ApiAccountingsIdGetResponse, ApiAccountingsIdGetResponses, ApiAccountingTransactionsGetCollectionData, ApiAccountingTransactionsGetCollectionResponse, ApiAccountingTransactionsGetCollectionResponses, ApiAccountingTransactionsIdGetData, ApiAccountingTransactionsIdGetError, ApiAccountingTransactionsIdGetErrors, ApiAccountingTransactionsIdGetResponse, ApiAccountingTransactionsIdGetResponses, ApiCategoriesGetCollectionData, ApiCategoriesGetCollectionResponse, ApiCategoriesGetCollectionResponses, ApiCategoriesIdDeleteData, ApiCategoriesIdDeleteError, ApiCategoriesIdDeleteErrors, ApiCategoriesIdDeleteResponse, ApiCategoriesIdDeleteResponses, ApiCategoriesIdOrSlugGetData, ApiCategoriesIdOrSlugGetError, ApiCategoriesIdOrSlugGetErrors, ApiCategoriesIdOrSlugGetResponse, ApiCategoriesIdOrSlugGetResponses, ApiCategoriesIdPatchData, ApiCategoriesIdPatchError, ApiCategoriesIdPatchErrors, ApiCategoriesIdPatchResponse, ApiCategoriesIdPatchResponses, ApiCategoriesPostData, ApiCategoriesPostError, ApiCategoriesPostErrors, ApiCategoriesPostResponse, ApiCategoriesPostResponses, ApiGatewayChargesGetCollectionData, ApiGatewayChargesGetCollectionResponse, ApiGatewayChargesGetCollectionResponses, ApiGatewayChargesIdGetData, ApiGatewayChargesIdGetError, ApiGatewayChargesIdGetErrors, ApiGatewayChargesIdGetResponse, ApiGatewayChargesIdGetResponses, ApiGatewayChargesIdPatchData, ApiGatewayChargesIdPatchError, ApiGatewayChargesIdPatchErrors, ApiGatewayChargesIdPatchResponse, ApiGatewayChargesIdPatchResponses, ApiGatewayChargestotalsGetCollectionData, ApiGatewayChargestotalsGetCollectionResponse, ApiGatewayChargestotalsGetCollectionResponses, ApiGatewayCheckoutsGetCollectionData, ApiGatewayCheckoutsGetCollectionResponse, ApiGatewayCheckoutsGetCollectionResponses, ApiGatewayCheckoutsIdGetData, ApiGatewayCheckoutsIdGetError, ApiGatewayCheckoutsIdGetErrors, ApiGatewayCheckoutsIdGetResponse, ApiGatewayCheckoutsIdGetResponses, ApiGatewayCheckoutsIdPatchData, ApiGatewayCheckoutsIdPatchError, ApiGatewayCheckoutsIdPatchErrors, ApiGatewayCheckoutsIdPatchResponse, ApiGatewayCheckoutsIdPatchResponses, ApiGatewayCheckoutsPostData, ApiGatewayCheckoutsPostError, ApiGatewayCheckoutsPostErrors, ApiGatewayCheckoutsPostResponse, ApiGatewayCheckoutsPostResponses, ApiGatewaysGetCollectionData, ApiGatewaysGetCollectionResponse, ApiGatewaysGetCollectionResponses, ApiGatewaysIdGetData, ApiGatewaysIdGetError, ApiGatewaysIdGetErrors, ApiGatewaysIdGetResponse, ApiGatewaysIdGetResponses, ApiMatchCallsGetCollectionData, ApiMatchCallsGetCollectionResponse, ApiMatchCallsGetCollectionResponses, ApiMatchCallsIdDeleteData, ApiMatchCallsIdDeleteError, ApiMatchCallsIdDeleteErrors, ApiMatchCallsIdDeleteResponse, ApiMatchCallsIdDeleteResponses, ApiMatchCallsIdGetData, ApiMatchCallsIdGetError, ApiMatchCallsIdGetErrors, ApiMatchCallsIdGetResponse, ApiMatchCallsIdGetResponses, ApiMatchCallsIdPatchData, ApiMatchCallsIdPatchError, ApiMatchCallsIdPatchErrors, ApiMatchCallsIdPatchResponse, ApiMatchCallsIdPatchResponses, ApiMatchCallsPostData, ApiMatchCallsPostError, ApiMatchCallsPostErrors, ApiMatchCallsPostResponse, ApiMatchCallsPostResponses, ApiMatchCallSubmissionsGetCollectionData, ApiMatchCallSubmissionsGetCollectionResponse, ApiMatchCallSubmissionsGetCollectionResponses, ApiMatchCallSubmissionsIdGetData, ApiMatchCallSubmissionsIdGetError, ApiMatchCallSubmissionsIdGetErrors, ApiMatchCallSubmissionsIdGetResponse, ApiMatchCallSubmissionsIdGetResponses, ApiMatchCallSubmissionsIdPatchData, ApiMatchCallSubmissionsIdPatchError, ApiMatchCallSubmissionsIdPatchErrors, ApiMatchCallSubmissionsIdPatchResponse, ApiMatchCallSubmissionsIdPatchResponses, ApiMatchCallSubmissionsPostData, ApiMatchCallSubmissionsPostError, ApiMatchCallSubmissionsPostErrors, ApiMatchCallSubmissionsPostResponse, ApiMatchCallSubmissionsPostResponses, ApiMatchFormulasGetCollectionData, ApiMatchFormulasGetCollectionResponse, ApiMatchFormulasGetCollectionResponses, ApiMatchFormulasNameGetData, ApiMatchFormulasNameGetError, ApiMatchFormulasNameGetErrors, ApiMatchFormulasNameGetResponse, ApiMatchFormulasNameGetResponses, ApiMatchRulesGetCollectionData, ApiMatchRulesGetCollectionResponse, ApiMatchRulesGetCollectionResponses, ApiMatchRulesNameGetData, ApiMatchRulesNameGetError, ApiMatchRulesNameGetErrors, ApiMatchRulesNameGetResponse, ApiMatchRulesNameGetResponses, ApiMatchStrategiesGetCollectionData, ApiMatchStrategiesGetCollectionResponse, ApiMatchStrategiesGetCollectionResponses, ApiMatchStrategiesIdDeleteData, ApiMatchStrategiesIdDeleteError, ApiMatchStrategiesIdDeleteErrors, ApiMatchStrategiesIdDeleteResponse, ApiMatchStrategiesIdDeleteResponses, ApiMatchStrategiesIdGetData, ApiMatchStrategiesIdGetError, ApiMatchStrategiesIdGetErrors, ApiMatchStrategiesIdGetResponse, ApiMatchStrategiesIdGetResponses, ApiMatchStrategiesIdPatchData, ApiMatchStrategiesIdPatchError, ApiMatchStrategiesIdPatchErrors, ApiMatchStrategiesIdPatchResponse, ApiMatchStrategiesIdPatchResponses, ApiMatchStrategiesPostData, ApiMatchStrategiesPostError, ApiMatchStrategiesPostErrors, ApiMatchStrategiesPostResponse, ApiMatchStrategiesPostResponses, ApiPingGetData, ApiPingGetError, ApiPingGetErrors, ApiPingGetResponse, ApiPingGetResponses, ApiProjectBudgetItemsGetCollectionData, ApiProjectBudgetItemsGetCollectionResponse, ApiProjectBudgetItemsGetCollectionResponses, ApiProjectBudgetItemsIdDeleteData, ApiProjectBudgetItemsIdDeleteError, ApiProjectBudgetItemsIdDeleteErrors, ApiProjectBudgetItemsIdDeleteResponse, ApiProjectBudgetItemsIdDeleteResponses, ApiProjectBudgetItemsIdGetData, ApiProjectBudgetItemsIdGetError, ApiProjectBudgetItemsIdGetErrors, ApiProjectBudgetItemsIdGetResponse, ApiProjectBudgetItemsIdGetResponses, ApiProjectBudgetItemsIdPatchData, ApiProjectBudgetItemsIdPatchError, ApiProjectBudgetItemsIdPatchErrors, ApiProjectBudgetItemsIdPatchResponse, ApiProjectBudgetItemsIdPatchResponses, ApiProjectBudgetItemsPostData, ApiProjectBudgetItemsPostError, ApiProjectBudgetItemsPostErrors, ApiProjectBudgetItemsPostResponse, ApiProjectBudgetItemsPostResponses, ApiProjectCollaborationCandidaciesGetCollectionData, ApiProjectCollaborationCandidaciesGetCollectionResponse, ApiProjectCollaborationCandidaciesGetCollectionResponses, ApiProjectCollaborationCandidaciesIdDeleteData, ApiProjectCollaborationCandidaciesIdDeleteError, ApiProjectCollaborationCandidaciesIdDeleteErrors, ApiProjectCollaborationCandidaciesIdDeleteResponse, ApiProjectCollaborationCandidaciesIdDeleteResponses, ApiProjectCollaborationCandidaciesIdGetData, ApiProjectCollaborationCandidaciesIdGetError, ApiProjectCollaborationCandidaciesIdGetErrors, ApiProjectCollaborationCandidaciesIdGetResponse, ApiProjectCollaborationCandidaciesIdGetResponses, ApiProjectCollaborationCandidaciesIdPatchData, ApiProjectCollaborationCandidaciesIdPatchError, ApiProjectCollaborationCandidaciesIdPatchErrors, ApiProjectCollaborationCandidaciesIdPatchResponse, ApiProjectCollaborationCandidaciesIdPatchResponses, ApiProjectCollaborationCandidaciesPostData, ApiProjectCollaborationCandidaciesPostError, ApiProjectCollaborationCandidaciesPostErrors, ApiProjectCollaborationCandidaciesPostResponse, ApiProjectCollaborationCandidaciesPostResponses, ApiProjectCollaborationsGetCollectionData, ApiProjectCollaborationsGetCollectionResponse, ApiProjectCollaborationsGetCollectionResponses, ApiProjectCollaborationsIdDeleteData, ApiProjectCollaborationsIdDeleteError, ApiProjectCollaborationsIdDeleteErrors, ApiProjectCollaborationsIdDeleteResponse, ApiProjectCollaborationsIdDeleteResponses, ApiProjectCollaborationsIdGetData, ApiProjectCollaborationsIdGetError, ApiProjectCollaborationsIdGetErrors, ApiProjectCollaborationsIdGetResponse, ApiProjectCollaborationsIdGetResponses, ApiProjectCollaborationsIdPatchData, ApiProjectCollaborationsIdPatchError, ApiProjectCollaborationsIdPatchErrors, ApiProjectCollaborationsIdPatchResponse, ApiProjectCollaborationsIdPatchResponses, ApiProjectCollaborationsPostData, ApiProjectCollaborationsPostError, ApiProjectCollaborationsPostErrors, ApiProjectCollaborationsPostResponse, ApiProjectCollaborationsPostResponses, ApiProjectRewardClaimsGetCollectionData, ApiProjectRewardClaimsGetCollectionResponse, ApiProjectRewardClaimsGetCollectionResponses, ApiProjectRewardClaimsIdDeleteData, ApiProjectRewardClaimsIdDeleteError, ApiProjectRewardClaimsIdDeleteErrors, ApiProjectRewardClaimsIdDeleteResponse, ApiProjectRewardClaimsIdDeleteResponses, ApiProjectRewardClaimsIdGetData, ApiProjectRewardClaimsIdGetError, ApiProjectRewardClaimsIdGetErrors, ApiProjectRewardClaimsIdGetResponse, ApiProjectRewardClaimsIdGetResponses, ApiProjectRewardClaimsIdPatchData, ApiProjectRewardClaimsIdPatchError, ApiProjectRewardClaimsIdPatchErrors, ApiProjectRewardClaimsIdPatchResponse, ApiProjectRewardClaimsIdPatchResponses, ApiProjectRewardClaimsPostData, ApiProjectRewardClaimsPostError, ApiProjectRewardClaimsPostErrors, ApiProjectRewardClaimsPostResponse, ApiProjectRewardClaimsPostResponses, ApiProjectRewardsGetCollectionData, ApiProjectRewardsGetCollectionResponse, ApiProjectRewardsGetCollectionResponses, ApiProjectRewardsIdDeleteData, ApiProjectRewardsIdDeleteError, ApiProjectRewardsIdDeleteErrors, ApiProjectRewardsIdDeleteResponse, ApiProjectRewardsIdDeleteResponses, ApiProjectRewardsIdGetData, ApiProjectRewardsIdGetError, ApiProjectRewardsIdGetErrors, ApiProjectRewardsIdGetResponse, ApiProjectRewardsIdGetResponses, ApiProjectRewardsIdPatchData, ApiProjectRewardsIdPatchError, ApiProjectRewardsIdPatchErrors, ApiProjectRewardsIdPatchResponse, ApiProjectRewardsIdPatchResponses, ApiProjectRewardsPostData, ApiProjectRewardsPostError, ApiProjectRewardsPostErrors, ApiProjectRewardsPostResponse, ApiProjectRewardsPostResponses, ApiProjectsGetCollectionData, ApiProjectsGetCollectionResponse, ApiProjectsGetCollectionResponses, ApiProjectsIdDeleteData, ApiProjectsIdDeleteError, ApiProjectsIdDeleteErrors, ApiProjectsIdDeleteResponse, ApiProjectsIdDeleteResponses, ApiProjectsIdOrSlugGetData, ApiProjectsIdOrSlugGetError, ApiProjectsIdOrSlugGetErrors, ApiProjectsIdOrSlugGetResponse, ApiProjectsIdOrSlugGetResponses, ApiProjectsIdPatchData, ApiProjectsIdPatchError, ApiProjectsIdPatchErrors, ApiProjectsIdPatchResponse, ApiProjectsIdPatchResponses, ApiProjectsPostData, ApiProjectsPostError, ApiProjectsPostErrors, ApiProjectsPostResponse, ApiProjectsPostResponses, ApiProjectSupportsGetCollectionData, ApiProjectSupportsGetCollectionResponse, ApiProjectSupportsGetCollectionResponses, ApiProjectSupportsIdGetData, ApiProjectSupportsIdGetError, ApiProjectSupportsIdGetErrors, ApiProjectSupportsIdGetResponse, ApiProjectSupportsIdGetResponses, ApiProjectSupportsIdPatchData, ApiProjectSupportsIdPatchError, ApiProjectSupportsIdPatchErrors, ApiProjectSupportsIdPatchResponse, ApiProjectSupportsIdPatchResponses, ApiProjectSupportsmoneyTotalGetCollectionData, ApiProjectSupportsmoneyTotalGetCollectionResponse, ApiProjectSupportsmoneyTotalGetCollectionResponses, ApiProjectUpdatesGetCollectionData, ApiProjectUpdatesGetCollectionResponse, ApiProjectUpdatesGetCollectionResponses, ApiProjectUpdatesIdDeleteData, ApiProjectUpdatesIdDeleteError, ApiProjectUpdatesIdDeleteErrors, ApiProjectUpdatesIdDeleteResponse, ApiProjectUpdatesIdDeleteResponses, ApiProjectUpdatesIdGetData, ApiProjectUpdatesIdGetError, ApiProjectUpdatesIdGetErrors, ApiProjectUpdatesIdGetResponse, ApiProjectUpdatesIdGetResponses, ApiProjectUpdatesIdPatchData, ApiProjectUpdatesIdPatchError, ApiProjectUpdatesIdPatchErrors, ApiProjectUpdatesIdPatchResponse, ApiProjectUpdatesIdPatchResponses, ApiProjectUpdatesPostData, ApiProjectUpdatesPostError, ApiProjectUpdatesPostErrors, ApiProjectUpdatesPostResponse, ApiProjectUpdatesPostResponses, ApiTipjarsGetCollectionData, ApiTipjarsGetCollectionResponse, ApiTipjarsGetCollectionResponses, ApiTipjarsIdDeleteData, ApiTipjarsIdDeleteError, ApiTipjarsIdDeleteErrors, ApiTipjarsIdDeleteResponse, ApiTipjarsIdDeleteResponses, ApiTipjarsIdGetData, ApiTipjarsIdGetError, ApiTipjarsIdGetErrors, ApiTipjarsIdGetResponse, ApiTipjarsIdGetResponses, ApiTipjarsIdPatchData, ApiTipjarsIdPatchError, ApiTipjarsIdPatchErrors, ApiTipjarsIdPatchResponse, ApiTipjarsIdPatchResponses, ApiTipjarsPostData, ApiTipjarsPostError, ApiTipjarsPostErrors, ApiTipjarsPostResponse, ApiTipjarsPostResponses, ApiUsersGetCollectionData, ApiUsersGetCollectionResponse, ApiUsersGetCollectionResponses, ApiUsersIdDeleteData, ApiUsersIdDeleteError, ApiUsersIdDeleteErrors, ApiUsersIdDeleteResponse, ApiUsersIdDeleteResponses, ApiUsersIdorganizationGetData, ApiUsersIdorganizationGetError, ApiUsersIdorganizationGetErrors, ApiUsersIdorganizationGetResponse, ApiUsersIdorganizationGetResponses, ApiUsersIdorganizationPatchData, ApiUsersIdorganizationPatchError, ApiUsersIdorganizationPatchErrors, ApiUsersIdorganizationPatchResponse, ApiUsersIdorganizationPatchResponses, ApiUsersIdOrHandleGetData, ApiUsersIdOrHandleGetError, ApiUsersIdOrHandleGetErrors, ApiUsersIdOrHandleGetResponse, ApiUsersIdOrHandleGetResponses, ApiUsersIdPatchData, ApiUsersIdPatchError, ApiUsersIdPatchErrors, ApiUsersIdPatchResponse, ApiUsersIdPatchResponses, ApiUsersIdpersonGetData, ApiUsersIdpersonGetError, ApiUsersIdpersonGetErrors, ApiUsersIdpersonGetResponse, ApiUsersIdpersonGetResponses, ApiUsersIdpersonPatchData, ApiUsersIdpersonPatchError, ApiUsersIdpersonPatchErrors, ApiUsersIdpersonPatchResponse, ApiUsersIdpersonPatchResponses, ApiUsersPostData, ApiUsersPostError, ApiUsersPostErrors, ApiUsersPostResponse, ApiUsersPostResponses, ApiVersionsGetCollectionData, ApiVersionsGetCollectionResponse, ApiVersionsGetCollectionResponses, ApiVersionsIdGetData, ApiVersionsIdGetError, ApiVersionsIdGetErrors, ApiVersionsIdGetResponse, ApiVersionsIdGetResponses, Budget, BudgetJsonld, BudgetSummary, BudgetSummaryJsonld, Category, CategoryJsonld, ChargeCreationDto, ChargeCreationDtoJsonld, ClientOptions, ConstraintViolationJson, ConstraintViolationJsonldJsonld, Conversion, ConversionJsonld, Error, ErrorJsonld, Gateway, GatewayCharge, GatewayChargeChargesTotalsDto, GatewayChargeChargesTotalsDtoJsonld, GatewayChargeChargeUpdationDto, GatewayChargeJsonld, GatewayCheckout, GatewayCheckoutCheckoutCreationDto, GatewayCheckoutCheckoutCreationDtoJsonld, GatewayCheckoutCheckoutUpdationDto, GatewayCheckoutJsonld, GatewayJsonld, GatewayLink, GatewayLinkJsonld, Link, LinkJsonld, MatchCall, MatchCallJsonld, MatchCallSubmission, MatchCallSubmissionJsonld, MatchCallSubmissionMatchCallSubmissionCreationDto, MatchCallSubmissionMatchCallSubmissionCreationDtoJsonld, MatchFormula, MatchFormulaJsonld, MatchRule, MatchRuleJsonld, MatchStrategy, MatchStrategyJsonld, Money, MoneyInput, MoneyInputJsonld, MoneyJsonld, MoneyOutput, MoneyOutputJsonld, Organization, OrganizationJsonld, Person, PersonJsonld, Ping, PingJsonld, Project, ProjectBudgetItem, ProjectBudgetItemJsonld, ProjectCalendar, ProjectCalendarJsonld, ProjectCollaboration, ProjectCollaborationCandidacy, ProjectCollaborationCandidacyJsonld, ProjectCollaborationJsonld, ProjectJsonld, ProjectProjectCreationDto, ProjectProjectCreationDtoJsonld, ProjectProjectUpdationDto, ProjectReward, ProjectRewardClaim, ProjectRewardClaimJsonld, ProjectRewardClaimRewardClaimCreationDto, ProjectRewardClaimRewardClaimCreationDtoJsonld, ProjectRewardClaimRewardClaimUpdationDto, ProjectRewardJsonld, ProjectSupport, ProjectSupportJsonld, ProjectSupportTotalizedMoney, ProjectSupportTotalizedMoneyJsonld, ProjectUpdate, ProjectUpdateJsonld, ProjectVideo, ProjectVideoJsonld, ShippingAddress, ShippingAddressJsonld, Territory, TerritoryJsonld, Tipjar, TipjarJsonld, TotalizedMoney, TotalizedMoneyJsonld, Tracking, TrackingJsonld, User, UserJsonld, UserUserSignupDto, UserUserSignupDtoJsonld, UserUserUpdationDto, Version, VersionJsonld } from './types.gen'; diff --git a/src/openapi/client/operation-paths.gen.ts b/src/openapi/client/operation-paths.gen.ts index 7d881680..f9cf6db7 100644 --- a/src/openapi/client/operation-paths.gen.ts +++ b/src/openapi/client/operation-paths.gen.ts @@ -112,6 +112,8 @@ export const apiProjectRewardClaimsIdDeleteUrl = '/v4/project_reward_claims/{id} export const apiProjectRewardClaimsIdGetUrl = '/v4/project_reward_claims/{id}'; +export const apiProjectRewardClaimsIdPatchUrl = '/v4/project_reward_claims/{id}'; + export const apiProjectSupportsmoneyTotalGetCollectionUrl = '/v4/project_supports/money_total'; export const apiProjectUpdatesGetCollectionUrl = '/v4/project_updates'; diff --git a/src/openapi/client/sdk.gen.ts b/src/openapi/client/sdk.gen.ts index 0194cdc6..19ed3cf6 100644 --- a/src/openapi/client/sdk.gen.ts +++ b/src/openapi/client/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, ClientMeta, Options as Options2, RequestResult, TDataShape } from './client'; import { client } from './client.gen'; -import type { ApiAccountingBalancePointsGetCollectionData, ApiAccountingBalancePointsGetCollectionResponses, ApiAccountingsGetCollectionData, ApiAccountingsGetCollectionResponses, ApiAccountingsIdGetData, ApiAccountingsIdGetErrors, ApiAccountingsIdGetResponses, ApiAccountingTransactionsGetCollectionData, ApiAccountingTransactionsGetCollectionResponses, ApiAccountingTransactionsIdGetData, ApiAccountingTransactionsIdGetErrors, ApiAccountingTransactionsIdGetResponses, ApiCategoriesGetCollectionData, ApiCategoriesGetCollectionResponses, ApiCategoriesIdDeleteData, ApiCategoriesIdDeleteErrors, ApiCategoriesIdDeleteResponses, ApiCategoriesIdOrSlugGetData, ApiCategoriesIdOrSlugGetErrors, ApiCategoriesIdOrSlugGetResponses, ApiCategoriesIdPatchData, ApiCategoriesIdPatchErrors, ApiCategoriesIdPatchResponses, ApiCategoriesPostData, ApiCategoriesPostErrors, ApiCategoriesPostResponses, ApiGatewayChargesGetCollectionData, ApiGatewayChargesGetCollectionResponses, ApiGatewayChargesIdGetData, ApiGatewayChargesIdGetErrors, ApiGatewayChargesIdGetResponses, ApiGatewayChargesIdPatchData, ApiGatewayChargesIdPatchErrors, ApiGatewayChargesIdPatchResponses, ApiGatewayChargestotalsGetCollectionData, ApiGatewayChargestotalsGetCollectionResponses, ApiGatewayCheckoutsGetCollectionData, ApiGatewayCheckoutsGetCollectionResponses, ApiGatewayCheckoutsIdGetData, ApiGatewayCheckoutsIdGetErrors, ApiGatewayCheckoutsIdGetResponses, ApiGatewayCheckoutsIdPatchData, ApiGatewayCheckoutsIdPatchErrors, ApiGatewayCheckoutsIdPatchResponses, ApiGatewayCheckoutsPostData, ApiGatewayCheckoutsPostErrors, ApiGatewayCheckoutsPostResponses, ApiGatewaysGetCollectionData, ApiGatewaysGetCollectionResponses, ApiGatewaysIdGetData, ApiGatewaysIdGetErrors, ApiGatewaysIdGetResponses, ApiMatchCallsGetCollectionData, ApiMatchCallsGetCollectionResponses, ApiMatchCallsIdDeleteData, ApiMatchCallsIdDeleteErrors, ApiMatchCallsIdDeleteResponses, ApiMatchCallsIdGetData, ApiMatchCallsIdGetErrors, ApiMatchCallsIdGetResponses, ApiMatchCallsIdPatchData, ApiMatchCallsIdPatchErrors, ApiMatchCallsIdPatchResponses, ApiMatchCallsPostData, ApiMatchCallsPostErrors, ApiMatchCallsPostResponses, ApiMatchCallSubmissionsGetCollectionData, ApiMatchCallSubmissionsGetCollectionResponses, ApiMatchCallSubmissionsIdGetData, ApiMatchCallSubmissionsIdGetErrors, ApiMatchCallSubmissionsIdGetResponses, ApiMatchCallSubmissionsIdPatchData, ApiMatchCallSubmissionsIdPatchErrors, ApiMatchCallSubmissionsIdPatchResponses, ApiMatchCallSubmissionsPostData, ApiMatchCallSubmissionsPostErrors, ApiMatchCallSubmissionsPostResponses, ApiMatchFormulasGetCollectionData, ApiMatchFormulasGetCollectionResponses, ApiMatchFormulasNameGetData, ApiMatchFormulasNameGetErrors, ApiMatchFormulasNameGetResponses, ApiMatchRulesGetCollectionData, ApiMatchRulesGetCollectionResponses, ApiMatchRulesNameGetData, ApiMatchRulesNameGetErrors, ApiMatchRulesNameGetResponses, ApiMatchStrategiesGetCollectionData, ApiMatchStrategiesGetCollectionResponses, ApiMatchStrategiesIdDeleteData, ApiMatchStrategiesIdDeleteErrors, ApiMatchStrategiesIdDeleteResponses, ApiMatchStrategiesIdGetData, ApiMatchStrategiesIdGetErrors, ApiMatchStrategiesIdGetResponses, ApiMatchStrategiesIdPatchData, ApiMatchStrategiesIdPatchErrors, ApiMatchStrategiesIdPatchResponses, ApiMatchStrategiesPostData, ApiMatchStrategiesPostErrors, ApiMatchStrategiesPostResponses, ApiPingGetData, ApiPingGetErrors, ApiPingGetResponses, ApiProjectBudgetItemsGetCollectionData, ApiProjectBudgetItemsGetCollectionResponses, ApiProjectBudgetItemsIdDeleteData, ApiProjectBudgetItemsIdDeleteErrors, ApiProjectBudgetItemsIdDeleteResponses, ApiProjectBudgetItemsIdGetData, ApiProjectBudgetItemsIdGetErrors, ApiProjectBudgetItemsIdGetResponses, ApiProjectBudgetItemsIdPatchData, ApiProjectBudgetItemsIdPatchErrors, ApiProjectBudgetItemsIdPatchResponses, ApiProjectBudgetItemsPostData, ApiProjectBudgetItemsPostErrors, ApiProjectBudgetItemsPostResponses, ApiProjectCollaborationCandidaciesGetCollectionData, ApiProjectCollaborationCandidaciesGetCollectionResponses, ApiProjectCollaborationCandidaciesIdDeleteData, ApiProjectCollaborationCandidaciesIdDeleteErrors, ApiProjectCollaborationCandidaciesIdDeleteResponses, ApiProjectCollaborationCandidaciesIdGetData, ApiProjectCollaborationCandidaciesIdGetErrors, ApiProjectCollaborationCandidaciesIdGetResponses, ApiProjectCollaborationCandidaciesIdPatchData, ApiProjectCollaborationCandidaciesIdPatchErrors, ApiProjectCollaborationCandidaciesIdPatchResponses, ApiProjectCollaborationCandidaciesPostData, ApiProjectCollaborationCandidaciesPostErrors, ApiProjectCollaborationCandidaciesPostResponses, ApiProjectCollaborationsGetCollectionData, ApiProjectCollaborationsGetCollectionResponses, ApiProjectCollaborationsIdDeleteData, ApiProjectCollaborationsIdDeleteErrors, ApiProjectCollaborationsIdDeleteResponses, ApiProjectCollaborationsIdGetData, ApiProjectCollaborationsIdGetErrors, ApiProjectCollaborationsIdGetResponses, ApiProjectCollaborationsIdPatchData, ApiProjectCollaborationsIdPatchErrors, ApiProjectCollaborationsIdPatchResponses, ApiProjectCollaborationsPostData, ApiProjectCollaborationsPostErrors, ApiProjectCollaborationsPostResponses, ApiProjectRewardClaimsGetCollectionData, ApiProjectRewardClaimsGetCollectionResponses, ApiProjectRewardClaimsIdDeleteData, ApiProjectRewardClaimsIdDeleteErrors, ApiProjectRewardClaimsIdDeleteResponses, ApiProjectRewardClaimsIdGetData, ApiProjectRewardClaimsIdGetErrors, ApiProjectRewardClaimsIdGetResponses, ApiProjectRewardClaimsPostData, ApiProjectRewardClaimsPostErrors, ApiProjectRewardClaimsPostResponses, ApiProjectRewardsGetCollectionData, ApiProjectRewardsGetCollectionResponses, ApiProjectRewardsIdDeleteData, ApiProjectRewardsIdDeleteErrors, ApiProjectRewardsIdDeleteResponses, ApiProjectRewardsIdGetData, ApiProjectRewardsIdGetErrors, ApiProjectRewardsIdGetResponses, ApiProjectRewardsIdPatchData, ApiProjectRewardsIdPatchErrors, ApiProjectRewardsIdPatchResponses, ApiProjectRewardsPostData, ApiProjectRewardsPostErrors, ApiProjectRewardsPostResponses, ApiProjectsGetCollectionData, ApiProjectsGetCollectionResponses, ApiProjectsIdDeleteData, ApiProjectsIdDeleteErrors, ApiProjectsIdDeleteResponses, ApiProjectsIdOrSlugGetData, ApiProjectsIdOrSlugGetErrors, ApiProjectsIdOrSlugGetResponses, ApiProjectsIdPatchData, ApiProjectsIdPatchErrors, ApiProjectsIdPatchResponses, ApiProjectsPostData, ApiProjectsPostErrors, ApiProjectsPostResponses, ApiProjectSupportsGetCollectionData, ApiProjectSupportsGetCollectionResponses, ApiProjectSupportsIdGetData, ApiProjectSupportsIdGetErrors, ApiProjectSupportsIdGetResponses, ApiProjectSupportsIdPatchData, ApiProjectSupportsIdPatchErrors, ApiProjectSupportsIdPatchResponses, ApiProjectSupportsmoneyTotalGetCollectionData, ApiProjectSupportsmoneyTotalGetCollectionResponses, ApiProjectUpdatesGetCollectionData, ApiProjectUpdatesGetCollectionResponses, ApiProjectUpdatesIdDeleteData, ApiProjectUpdatesIdDeleteErrors, ApiProjectUpdatesIdDeleteResponses, ApiProjectUpdatesIdGetData, ApiProjectUpdatesIdGetErrors, ApiProjectUpdatesIdGetResponses, ApiProjectUpdatesIdPatchData, ApiProjectUpdatesIdPatchErrors, ApiProjectUpdatesIdPatchResponses, ApiProjectUpdatesPostData, ApiProjectUpdatesPostErrors, ApiProjectUpdatesPostResponses, ApiTipjarsGetCollectionData, ApiTipjarsGetCollectionResponses, ApiTipjarsIdDeleteData, ApiTipjarsIdDeleteErrors, ApiTipjarsIdDeleteResponses, ApiTipjarsIdGetData, ApiTipjarsIdGetErrors, ApiTipjarsIdGetResponses, ApiTipjarsIdPatchData, ApiTipjarsIdPatchErrors, ApiTipjarsIdPatchResponses, ApiTipjarsPostData, ApiTipjarsPostErrors, ApiTipjarsPostResponses, ApiUsersGetCollectionData, ApiUsersGetCollectionResponses, ApiUsersIdDeleteData, ApiUsersIdDeleteErrors, ApiUsersIdDeleteResponses, ApiUsersIdorganizationGetData, ApiUsersIdorganizationGetErrors, ApiUsersIdorganizationGetResponses, ApiUsersIdorganizationPatchData, ApiUsersIdorganizationPatchErrors, ApiUsersIdorganizationPatchResponses, ApiUsersIdOrHandleGetData, ApiUsersIdOrHandleGetErrors, ApiUsersIdOrHandleGetResponses, ApiUsersIdPatchData, ApiUsersIdPatchErrors, ApiUsersIdPatchResponses, ApiUsersIdpersonGetData, ApiUsersIdpersonGetErrors, ApiUsersIdpersonGetResponses, ApiUsersIdpersonPatchData, ApiUsersIdpersonPatchErrors, ApiUsersIdpersonPatchResponses, ApiUsersPostData, ApiUsersPostErrors, ApiUsersPostResponses, ApiVersionsGetCollectionData, ApiVersionsGetCollectionResponses, ApiVersionsIdGetData, ApiVersionsIdGetErrors, ApiVersionsIdGetResponses } from './types.gen'; +import type { ApiAccountingBalancePointsGetCollectionData, ApiAccountingBalancePointsGetCollectionResponses, ApiAccountingsGetCollectionData, ApiAccountingsGetCollectionResponses, ApiAccountingsIdGetData, ApiAccountingsIdGetErrors, ApiAccountingsIdGetResponses, ApiAccountingTransactionsGetCollectionData, ApiAccountingTransactionsGetCollectionResponses, ApiAccountingTransactionsIdGetData, ApiAccountingTransactionsIdGetErrors, ApiAccountingTransactionsIdGetResponses, ApiCategoriesGetCollectionData, ApiCategoriesGetCollectionResponses, ApiCategoriesIdDeleteData, ApiCategoriesIdDeleteErrors, ApiCategoriesIdDeleteResponses, ApiCategoriesIdOrSlugGetData, ApiCategoriesIdOrSlugGetErrors, ApiCategoriesIdOrSlugGetResponses, ApiCategoriesIdPatchData, ApiCategoriesIdPatchErrors, ApiCategoriesIdPatchResponses, ApiCategoriesPostData, ApiCategoriesPostErrors, ApiCategoriesPostResponses, ApiGatewayChargesGetCollectionData, ApiGatewayChargesGetCollectionResponses, ApiGatewayChargesIdGetData, ApiGatewayChargesIdGetErrors, ApiGatewayChargesIdGetResponses, ApiGatewayChargesIdPatchData, ApiGatewayChargesIdPatchErrors, ApiGatewayChargesIdPatchResponses, ApiGatewayChargestotalsGetCollectionData, ApiGatewayChargestotalsGetCollectionResponses, ApiGatewayCheckoutsGetCollectionData, ApiGatewayCheckoutsGetCollectionResponses, ApiGatewayCheckoutsIdGetData, ApiGatewayCheckoutsIdGetErrors, ApiGatewayCheckoutsIdGetResponses, ApiGatewayCheckoutsIdPatchData, ApiGatewayCheckoutsIdPatchErrors, ApiGatewayCheckoutsIdPatchResponses, ApiGatewayCheckoutsPostData, ApiGatewayCheckoutsPostErrors, ApiGatewayCheckoutsPostResponses, ApiGatewaysGetCollectionData, ApiGatewaysGetCollectionResponses, ApiGatewaysIdGetData, ApiGatewaysIdGetErrors, ApiGatewaysIdGetResponses, ApiMatchCallsGetCollectionData, ApiMatchCallsGetCollectionResponses, ApiMatchCallsIdDeleteData, ApiMatchCallsIdDeleteErrors, ApiMatchCallsIdDeleteResponses, ApiMatchCallsIdGetData, ApiMatchCallsIdGetErrors, ApiMatchCallsIdGetResponses, ApiMatchCallsIdPatchData, ApiMatchCallsIdPatchErrors, ApiMatchCallsIdPatchResponses, ApiMatchCallsPostData, ApiMatchCallsPostErrors, ApiMatchCallsPostResponses, ApiMatchCallSubmissionsGetCollectionData, ApiMatchCallSubmissionsGetCollectionResponses, ApiMatchCallSubmissionsIdGetData, ApiMatchCallSubmissionsIdGetErrors, ApiMatchCallSubmissionsIdGetResponses, ApiMatchCallSubmissionsIdPatchData, ApiMatchCallSubmissionsIdPatchErrors, ApiMatchCallSubmissionsIdPatchResponses, ApiMatchCallSubmissionsPostData, ApiMatchCallSubmissionsPostErrors, ApiMatchCallSubmissionsPostResponses, ApiMatchFormulasGetCollectionData, ApiMatchFormulasGetCollectionResponses, ApiMatchFormulasNameGetData, ApiMatchFormulasNameGetErrors, ApiMatchFormulasNameGetResponses, ApiMatchRulesGetCollectionData, ApiMatchRulesGetCollectionResponses, ApiMatchRulesNameGetData, ApiMatchRulesNameGetErrors, ApiMatchRulesNameGetResponses, ApiMatchStrategiesGetCollectionData, ApiMatchStrategiesGetCollectionResponses, ApiMatchStrategiesIdDeleteData, ApiMatchStrategiesIdDeleteErrors, ApiMatchStrategiesIdDeleteResponses, ApiMatchStrategiesIdGetData, ApiMatchStrategiesIdGetErrors, ApiMatchStrategiesIdGetResponses, ApiMatchStrategiesIdPatchData, ApiMatchStrategiesIdPatchErrors, ApiMatchStrategiesIdPatchResponses, ApiMatchStrategiesPostData, ApiMatchStrategiesPostErrors, ApiMatchStrategiesPostResponses, ApiPingGetData, ApiPingGetErrors, ApiPingGetResponses, ApiProjectBudgetItemsGetCollectionData, ApiProjectBudgetItemsGetCollectionResponses, ApiProjectBudgetItemsIdDeleteData, ApiProjectBudgetItemsIdDeleteErrors, ApiProjectBudgetItemsIdDeleteResponses, ApiProjectBudgetItemsIdGetData, ApiProjectBudgetItemsIdGetErrors, ApiProjectBudgetItemsIdGetResponses, ApiProjectBudgetItemsIdPatchData, ApiProjectBudgetItemsIdPatchErrors, ApiProjectBudgetItemsIdPatchResponses, ApiProjectBudgetItemsPostData, ApiProjectBudgetItemsPostErrors, ApiProjectBudgetItemsPostResponses, ApiProjectCollaborationCandidaciesGetCollectionData, ApiProjectCollaborationCandidaciesGetCollectionResponses, ApiProjectCollaborationCandidaciesIdDeleteData, ApiProjectCollaborationCandidaciesIdDeleteErrors, ApiProjectCollaborationCandidaciesIdDeleteResponses, ApiProjectCollaborationCandidaciesIdGetData, ApiProjectCollaborationCandidaciesIdGetErrors, ApiProjectCollaborationCandidaciesIdGetResponses, ApiProjectCollaborationCandidaciesIdPatchData, ApiProjectCollaborationCandidaciesIdPatchErrors, ApiProjectCollaborationCandidaciesIdPatchResponses, ApiProjectCollaborationCandidaciesPostData, ApiProjectCollaborationCandidaciesPostErrors, ApiProjectCollaborationCandidaciesPostResponses, ApiProjectCollaborationsGetCollectionData, ApiProjectCollaborationsGetCollectionResponses, ApiProjectCollaborationsIdDeleteData, ApiProjectCollaborationsIdDeleteErrors, ApiProjectCollaborationsIdDeleteResponses, ApiProjectCollaborationsIdGetData, ApiProjectCollaborationsIdGetErrors, ApiProjectCollaborationsIdGetResponses, ApiProjectCollaborationsIdPatchData, ApiProjectCollaborationsIdPatchErrors, ApiProjectCollaborationsIdPatchResponses, ApiProjectCollaborationsPostData, ApiProjectCollaborationsPostErrors, ApiProjectCollaborationsPostResponses, ApiProjectRewardClaimsGetCollectionData, ApiProjectRewardClaimsGetCollectionResponses, ApiProjectRewardClaimsIdDeleteData, ApiProjectRewardClaimsIdDeleteErrors, ApiProjectRewardClaimsIdDeleteResponses, ApiProjectRewardClaimsIdGetData, ApiProjectRewardClaimsIdGetErrors, ApiProjectRewardClaimsIdGetResponses, ApiProjectRewardClaimsIdPatchData, ApiProjectRewardClaimsIdPatchErrors, ApiProjectRewardClaimsIdPatchResponses, ApiProjectRewardClaimsPostData, ApiProjectRewardClaimsPostErrors, ApiProjectRewardClaimsPostResponses, ApiProjectRewardsGetCollectionData, ApiProjectRewardsGetCollectionResponses, ApiProjectRewardsIdDeleteData, ApiProjectRewardsIdDeleteErrors, ApiProjectRewardsIdDeleteResponses, ApiProjectRewardsIdGetData, ApiProjectRewardsIdGetErrors, ApiProjectRewardsIdGetResponses, ApiProjectRewardsIdPatchData, ApiProjectRewardsIdPatchErrors, ApiProjectRewardsIdPatchResponses, ApiProjectRewardsPostData, ApiProjectRewardsPostErrors, ApiProjectRewardsPostResponses, ApiProjectsGetCollectionData, ApiProjectsGetCollectionResponses, ApiProjectsIdDeleteData, ApiProjectsIdDeleteErrors, ApiProjectsIdDeleteResponses, ApiProjectsIdOrSlugGetData, ApiProjectsIdOrSlugGetErrors, ApiProjectsIdOrSlugGetResponses, ApiProjectsIdPatchData, ApiProjectsIdPatchErrors, ApiProjectsIdPatchResponses, ApiProjectsPostData, ApiProjectsPostErrors, ApiProjectsPostResponses, ApiProjectSupportsGetCollectionData, ApiProjectSupportsGetCollectionResponses, ApiProjectSupportsIdGetData, ApiProjectSupportsIdGetErrors, ApiProjectSupportsIdGetResponses, ApiProjectSupportsIdPatchData, ApiProjectSupportsIdPatchErrors, ApiProjectSupportsIdPatchResponses, ApiProjectSupportsmoneyTotalGetCollectionData, ApiProjectSupportsmoneyTotalGetCollectionResponses, ApiProjectUpdatesGetCollectionData, ApiProjectUpdatesGetCollectionResponses, ApiProjectUpdatesIdDeleteData, ApiProjectUpdatesIdDeleteErrors, ApiProjectUpdatesIdDeleteResponses, ApiProjectUpdatesIdGetData, ApiProjectUpdatesIdGetErrors, ApiProjectUpdatesIdGetResponses, ApiProjectUpdatesIdPatchData, ApiProjectUpdatesIdPatchErrors, ApiProjectUpdatesIdPatchResponses, ApiProjectUpdatesPostData, ApiProjectUpdatesPostErrors, ApiProjectUpdatesPostResponses, ApiTipjarsGetCollectionData, ApiTipjarsGetCollectionResponses, ApiTipjarsIdDeleteData, ApiTipjarsIdDeleteErrors, ApiTipjarsIdDeleteResponses, ApiTipjarsIdGetData, ApiTipjarsIdGetErrors, ApiTipjarsIdGetResponses, ApiTipjarsIdPatchData, ApiTipjarsIdPatchErrors, ApiTipjarsIdPatchResponses, ApiTipjarsPostData, ApiTipjarsPostErrors, ApiTipjarsPostResponses, ApiUsersGetCollectionData, ApiUsersGetCollectionResponses, ApiUsersIdDeleteData, ApiUsersIdDeleteErrors, ApiUsersIdDeleteResponses, ApiUsersIdorganizationGetData, ApiUsersIdorganizationGetErrors, ApiUsersIdorganizationGetResponses, ApiUsersIdorganizationPatchData, ApiUsersIdorganizationPatchErrors, ApiUsersIdorganizationPatchResponses, ApiUsersIdOrHandleGetData, ApiUsersIdOrHandleGetErrors, ApiUsersIdOrHandleGetResponses, ApiUsersIdPatchData, ApiUsersIdPatchErrors, ApiUsersIdPatchResponses, ApiUsersIdpersonGetData, ApiUsersIdpersonGetErrors, ApiUsersIdpersonGetResponses, ApiUsersIdpersonPatchData, ApiUsersIdpersonPatchErrors, ApiUsersIdpersonPatchResponses, ApiUsersPostData, ApiUsersPostErrors, ApiUsersPostResponses, ApiVersionsGetCollectionData, ApiVersionsGetCollectionResponses, ApiVersionsIdGetData, ApiVersionsIdGetErrors, ApiVersionsIdGetResponses } from './types.gen'; export type Options = Options2 & { /** @@ -906,6 +906,21 @@ export const apiProjectRewardClaimsIdGet = (options: Options): RequestResult => (options.client ?? client).patch({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/v4/project_reward_claims/{id}', + ...options, + headers: { + 'Content-Type': 'application/merge-patch+json', + ...options.headers + } +}); + /** * Retrieves the collection of ProjectSupport resources. * diff --git a/src/openapi/client/types.gen.ts b/src/openapi/client/types.gen.ts index e49976df..c2009ce4 100644 --- a/src/openapi/client/types.gen.ts +++ b/src/openapi/client/types.gen.ts @@ -858,6 +858,10 @@ export type GatewayLink = { * The link relation type, which serves as an ID for a link that unambiguously describes the semantics of the link. */ rel?: string | null; + /** + * The known organization behind this link. + */ + readonly org?: 'instagram' | 'facebook' | 'github' | 'linkedin' | 'identica' | 'unknown'; }; export type GatewayLinkJsonld = { @@ -886,6 +890,10 @@ export type GatewayLinkJsonld = { * The link relation type, which serves as an ID for a link that unambiguously describes the semantics of the link. */ rel?: string | null; + /** + * The known organization behind this link. + */ + readonly org?: 'instagram' | 'facebook' | 'github' | 'linkedin' | 'identica' | 'unknown'; }; export type Link = { @@ -897,6 +905,10 @@ export type Link = { * The link relation type, which serves as an ID for a link that unambiguously describes the semantics of the link. */ rel?: string | null; + /** + * The known organization behind this link. + */ + readonly org?: 'instagram' | 'facebook' | 'github' | 'linkedin' | 'identica' | 'unknown'; }; export type LinkJsonld = { @@ -915,6 +927,10 @@ export type LinkJsonld = { * The link relation type, which serves as an ID for a link that unambiguously describes the semantics of the link. */ rel?: string | null; + /** + * The known organization behind this link. + */ + readonly org?: 'instagram' | 'facebook' | 'github' | 'linkedin' | 'identica' | 'unknown'; }; /** @@ -1581,6 +1597,10 @@ export type Project = { * A list of the BudgetItems composing the budget of this Project. */ readonly budgetItems?: Array; + /** + * A list of the ProjectCollaborations this Project has. + */ + readonly collaborations?: Array; /** * A list of the ProjectUpdates this Project has. */ @@ -1804,6 +1824,10 @@ export type ProjectJsonld = { * A list of the BudgetItems composing the budget of this Project. */ readonly budgetItems?: Array; + /** + * A list of the ProjectCollaborations this Project has. + */ + readonly collaborations?: Array; /** * A list of the ProjectUpdates this Project has. */ @@ -2168,26 +2192,40 @@ export type ProjectRewardJsonld = { }; /** - * A ProjectRewardClaim represents the will of an User who wishes to obtain one ProjectReward. + * A ProjectRewardClaim represents the will of an User who wishes to obtain one ProjectReward.\ + * \ + * Once created ProjectRewardClaims can only be deleted by the User who owns it, + * while their status can only be updated by the User who owns the Project of the claimed ProjectReward. */ export type ProjectRewardClaim = { readonly id?: number; /** - * The User claiming the ProjectReward. + * The User claiming the ProjectReward. Derived from the GatewayCharge. */ - readonly owner?: string; + owner?: string; /** * The GatewayCharge granting access to the ProjectReward. */ - charge: string; + charge?: string; /** * The ProjectReward being claimed. */ - reward: string; + reward?: string; + /** + * The point at which the claim is in its life-cylce. + */ + status?: 'in_pending' | 'fulfilled'; + /** + * Only used when the reward is a physical object that needs to be shipped. + */ + shippingAddress?: ShippingAddress | null; }; /** - * A ProjectRewardClaim represents the will of an User who wishes to obtain one ProjectReward. + * A ProjectRewardClaim represents the will of an User who wishes to obtain one ProjectReward.\ + * \ + * Once created ProjectRewardClaims can only be deleted by the User who owns it, + * while their status can only be updated by the User who owns the Project of the claimed ProjectReward. */ export type ProjectRewardClaimRewardClaimCreationDto = { /** @@ -2198,10 +2236,17 @@ export type ProjectRewardClaimRewardClaimCreationDto = { * The ProjectReward being claimed. */ reward: string; + /** + * If the reward is a physical object that needs to be delivered to an specific place. + */ + shippingAddress?: ShippingAddress | null; }; /** - * A ProjectRewardClaim represents the will of an User who wishes to obtain one ProjectReward. + * A ProjectRewardClaim represents the will of an User who wishes to obtain one ProjectReward.\ + * \ + * Once created ProjectRewardClaims can only be deleted by the User who owns it, + * while their status can only be updated by the User who owns the Project of the claimed ProjectReward. */ export type ProjectRewardClaimRewardClaimCreationDtoJsonld = { /** @@ -2212,10 +2257,35 @@ export type ProjectRewardClaimRewardClaimCreationDtoJsonld = { * The ProjectReward being claimed. */ reward: string; + /** + * If the reward is a physical object that needs to be delivered to an specific place. + */ + shippingAddress?: ShippingAddressJsonld | null; }; /** - * A ProjectRewardClaim represents the will of an User who wishes to obtain one ProjectReward. + * A ProjectRewardClaim represents the will of an User who wishes to obtain one ProjectReward.\ + * \ + * Once created ProjectRewardClaims can only be deleted by the User who owns it, + * while their status can only be updated by the User who owns the Project of the claimed ProjectReward. + */ +export type ProjectRewardClaimRewardClaimUpdationDto = { + /** + * The point at which the claim over the reward is.\ + * May only be updated by admins or the User who owns the Project of the ProjectReward. + */ + status?: 'in_pending' | 'fulfilled'; + /** + * If the reward is a physical object that needs to be delivered to an specific place. + */ + shippingAddress?: ShippingAddress | null; +}; + +/** + * A ProjectRewardClaim represents the will of an User who wishes to obtain one ProjectReward.\ + * \ + * Once created ProjectRewardClaims can only be deleted by the User who owns it, + * while their status can only be updated by the User who owns the Project of the claimed ProjectReward. */ export type ProjectRewardClaimJsonld = { readonly '@context'?: string | { @@ -2227,17 +2297,25 @@ export type ProjectRewardClaimJsonld = { readonly '@type'?: string; readonly id?: number; /** - * The User claiming the ProjectReward. + * The User claiming the ProjectReward. Derived from the GatewayCharge. */ - readonly owner?: string; + owner?: string; /** * The GatewayCharge granting access to the ProjectReward. */ - charge: string; + charge?: string; /** * The ProjectReward being claimed. */ - reward: string; + reward?: string; + /** + * The point at which the claim is in its life-cylce. + */ + status?: 'in_pending' | 'fulfilled'; + /** + * Only used when the reward is a physical object that needs to be shipped. + */ + shippingAddress?: ShippingAddressJsonld | null; }; /** @@ -2475,6 +2553,77 @@ export type ProjectVideoJsonld = { thumbnail?: string | null; }; +export type ShippingAddress = { + /** + * First name(s) of the person receiving the shipment. + */ + firstName: string; + /** + * Last name(s) of the person receiving the shipment. + */ + lastName: string; + /** + * Line 1: usually street name and number. + */ + addressLine1: string; + /** + * Line 2: additional data like apartment number, door, etc. + */ + addressLine2?: string | null; + /** + * Name of the city, or the lowest-available type of settlement to which the address lines belong. + */ + city: string; + /** + * Postal/PIN/ZIP code to which the address lines belong. + */ + postCode: string; + /** + * ISO 3166-1 alpha-2 two-letter country code.\ + * e.g: ES (Spain). + */ + country: string; +}; + +export type ShippingAddressJsonld = { + readonly '@context'?: string | { + '@vocab': string; + hydra: 'http://www.w3.org/ns/hydra/core#'; + [key: string]: unknown; + }; + readonly '@id'?: string; + readonly '@type'?: string; + /** + * First name(s) of the person receiving the shipment. + */ + firstName: string; + /** + * Last name(s) of the person receiving the shipment. + */ + lastName: string; + /** + * Line 1: usually street name and number. + */ + addressLine1: string; + /** + * Line 2: additional data like apartment number, door, etc. + */ + addressLine2?: string | null; + /** + * Name of the city, or the lowest-available type of settlement to which the address lines belong. + */ + city: string; + /** + * Postal/PIN/ZIP code to which the address lines belong. + */ + postCode: string; + /** + * ISO 3166-1 alpha-2 two-letter country code.\ + * e.g: ES (Spain). + */ + country: string; +}; + export type Territory = { /** * ISO 3166-1 alpha-2 two-letter country code.\ @@ -2689,7 +2838,7 @@ export type UserUserSignupDto = { */ email: string; /** - * The auth password for the new User. Plaintext string, + * The authentication password for the new User. Plaintext string, * will be hashed by the API. */ password: string; @@ -2708,7 +2857,7 @@ export type UserUserSignupDtoJsonld = { */ email: string; /** - * The auth password for the new User. Plaintext string, + * The authentication password for the new User. Plaintext string, * will be hashed by the API. */ password: string; @@ -2718,6 +2867,50 @@ export type UserUserSignupDtoJsonld = { type: 'individual' | 'organization'; }; +/** + * Users represent people who interact with the platform. + */ +export type UserUserUpdationDto = { + /** + * A unique, non white space, byte-safe string identifier for this User. + */ + handle?: string; + /** + * The User's given email address. Only available to themselves and platform administrators. + */ + email?: string; + /** + * The authentication password for the User. Plaintext string, + * will be hashed by the API. + */ + password?: string; + /** + * URL to the avatar image of this User. + */ + avatar?: string; + /** + * Is this User for an individual acting on their own or a group of individuals? + */ + type?: 'individual' | 'organization'; + /** + * A list of the roles assigned to this User. Admin scoped property. + */ + roles?: Array; + /** + * A list of absolute URLs.\ + * e.g: social profiles, personal website. + */ + links?: Array; + /** + * ISO 3166 data about the Users's location territory. + */ + territory?: Territory; + /** + * Free-form rich text description for the User. + */ + description?: string; +}; + /** * Users represent people who interact with the platform. */ @@ -5268,6 +5461,11 @@ export type ApiProjectRewardClaimsGetCollectionData = { 'charge[]'?: Array; reward?: string; 'reward[]'?: Array; + status?: string; + 'status[]'?: Array; + 'shippingAddress.city'?: string; + 'shippingAddress.postCode'?: string; + 'shippingAddress.country'?: string; }; url: '/v4/project_reward_claims'; }; @@ -5326,6 +5524,10 @@ export type ApiProjectRewardClaimsIdDeleteData = { }; export type ApiProjectRewardClaimsIdDeleteErrors = { + /** + * Forbidden + */ + 403: ErrorJsonld; /** * Not found */ @@ -5373,6 +5575,47 @@ export type ApiProjectRewardClaimsIdGetResponses = { export type ApiProjectRewardClaimsIdGetResponse = ApiProjectRewardClaimsIdGetResponses[keyof ApiProjectRewardClaimsIdGetResponses]; +export type ApiProjectRewardClaimsIdPatchData = { + /** + * The updated ProjectRewardClaim resource + */ + body: ProjectRewardClaimRewardClaimUpdationDto; + path: { + /** + * ProjectRewardClaim identifier + */ + id: string; + }; + query?: never; + url: '/v4/project_reward_claims/{id}'; +}; + +export type ApiProjectRewardClaimsIdPatchErrors = { + /** + * Invalid input + */ + 400: ErrorJsonld; + /** + * Not found + */ + 404: ErrorJsonld; + /** + * An error occurred + */ + 422: ConstraintViolationJsonldJsonld; +}; + +export type ApiProjectRewardClaimsIdPatchError = ApiProjectRewardClaimsIdPatchErrors[keyof ApiProjectRewardClaimsIdPatchErrors]; + +export type ApiProjectRewardClaimsIdPatchResponses = { + /** + * ProjectRewardClaim resource updated + */ + 200: ProjectRewardClaim; +}; + +export type ApiProjectRewardClaimsIdPatchResponse = ApiProjectRewardClaimsIdPatchResponses[keyof ApiProjectRewardClaimsIdPatchResponses]; + export type ApiProjectSupportsGetCollectionData = { body?: never; path?: never; @@ -6003,7 +6246,7 @@ export type ApiUsersIdPatchData = { /** * The updated User resource */ - body: User; + body: UserUserUpdationDto; path: { /** * User identifier diff --git a/src/openapi/client/zod.gen.ts b/src/openapi/client/zod.gen.ts index b8dd44de..785ba510 100644 --- a/src/openapi/client/zod.gen.ts +++ b/src/openapi/client/zod.gen.ts @@ -175,7 +175,15 @@ export const zGatewayLink = z.object({ method: z.string().optional(), type: z.enum(['debug', 'payment']).optional(), url: z.string().optional(), - rel: z.string().nullish() + rel: z.string().nullish(), + org: z.enum([ + 'instagram', + 'facebook', + 'github', + 'linkedin', + 'identica', + 'unknown' + ]).readonly().optional() }); export const zGatewayLinkJsonld = z.object({ @@ -191,12 +199,28 @@ export const zGatewayLinkJsonld = z.object({ method: z.string().optional(), type: z.enum(['debug', 'payment']).optional(), url: z.string().optional(), - rel: z.string().nullish() + rel: z.string().nullish(), + org: z.enum([ + 'instagram', + 'facebook', + 'github', + 'linkedin', + 'identica', + 'unknown' + ]).readonly().optional() }); export const zLink = z.object({ url: z.string().optional(), - rel: z.string().nullish() + rel: z.string().nullish(), + org: z.enum([ + 'instagram', + 'facebook', + 'github', + 'linkedin', + 'identica', + 'unknown' + ]).readonly().optional() }); export const zLinkJsonld = z.object({ @@ -210,7 +234,15 @@ export const zLinkJsonld = z.object({ '@id': z.string().readonly().optional(), '@type': z.string().readonly().optional(), url: z.string().optional(), - rel: z.string().nullish() + rel: z.string().nullish(), + org: z.enum([ + 'instagram', + 'facebook', + 'github', + 'linkedin', + 'identica', + 'unknown' + ]).readonly().optional() }); /** @@ -734,51 +766,6 @@ export const zProjectRewardJsonld = z.object({ dateUpdated: z.iso.datetime().readonly().optional() }); -/** - * A ProjectRewardClaim represents the will of an User who wishes to obtain one ProjectReward. - */ -export const zProjectRewardClaim = z.object({ - id: z.int().readonly().optional(), - owner: z.string().readonly().optional(), - charge: z.string(), - reward: z.string() -}); - -/** - * A ProjectRewardClaim represents the will of an User who wishes to obtain one ProjectReward. - */ -export const zProjectRewardClaimRewardClaimCreationDto = z.object({ - charge: z.string(), - reward: z.string() -}); - -/** - * A ProjectRewardClaim represents the will of an User who wishes to obtain one ProjectReward. - */ -export const zProjectRewardClaimRewardClaimCreationDtoJsonld = z.object({ - charge: z.string(), - reward: z.string() -}); - -/** - * A ProjectRewardClaim represents the will of an User who wishes to obtain one ProjectReward. - */ -export const zProjectRewardClaimJsonld = z.object({ - '@context': z.union([ - z.string(), - z.object({ - '@vocab': z.string(), - hydra: z.enum(['http://www.w3.org/ns/hydra/core#']) - }) - ]).optional(), - '@id': z.string().readonly().optional(), - '@type': z.string().readonly().optional(), - id: z.int().readonly().optional(), - owner: z.string().readonly().optional(), - charge: z.string(), - reward: z.string() -}); - /** * ProjectSupports gather Transactions going from one same origin to one same Project.\ * \ @@ -875,6 +862,109 @@ export const zProjectVideoJsonld = z.object({ thumbnail: z.string().nullish() }); +export const zShippingAddress = z.object({ + firstName: z.string(), + lastName: z.string(), + addressLine1: z.string(), + addressLine2: z.string().nullish(), + city: z.string(), + postCode: z.string(), + country: z.string() +}); + +/** + * A ProjectRewardClaim represents the will of an User who wishes to obtain one ProjectReward.\ + * \ + * Once created ProjectRewardClaims can only be deleted by the User who owns it, + * while their status can only be updated by the User who owns the Project of the claimed ProjectReward. + */ +export const zProjectRewardClaim = z.object({ + id: z.int().readonly().optional(), + owner: z.string().optional(), + charge: z.string().optional(), + reward: z.string().optional(), + status: z.enum(['in_pending', 'fulfilled']).optional(), + shippingAddress: zShippingAddress.nullish() +}); + +/** + * A ProjectRewardClaim represents the will of an User who wishes to obtain one ProjectReward.\ + * \ + * Once created ProjectRewardClaims can only be deleted by the User who owns it, + * while their status can only be updated by the User who owns the Project of the claimed ProjectReward. + */ +export const zProjectRewardClaimRewardClaimCreationDto = z.object({ + charge: z.string(), + reward: z.string(), + shippingAddress: zShippingAddress.nullish() +}); + +/** + * A ProjectRewardClaim represents the will of an User who wishes to obtain one ProjectReward.\ + * \ + * Once created ProjectRewardClaims can only be deleted by the User who owns it, + * while their status can only be updated by the User who owns the Project of the claimed ProjectReward. + */ +export const zProjectRewardClaimRewardClaimUpdationDto = z.object({ + status: z.enum(['in_pending', 'fulfilled']).optional(), + shippingAddress: zShippingAddress.nullish() +}); + +export const zShippingAddressJsonld = z.object({ + '@context': z.union([ + z.string(), + z.object({ + '@vocab': z.string(), + hydra: z.enum(['http://www.w3.org/ns/hydra/core#']) + }) + ]).optional(), + '@id': z.string().readonly().optional(), + '@type': z.string().readonly().optional(), + firstName: z.string(), + lastName: z.string(), + addressLine1: z.string(), + addressLine2: z.string().nullish(), + city: z.string(), + postCode: z.string(), + country: z.string() +}); + +/** + * A ProjectRewardClaim represents the will of an User who wishes to obtain one ProjectReward.\ + * \ + * Once created ProjectRewardClaims can only be deleted by the User who owns it, + * while their status can only be updated by the User who owns the Project of the claimed ProjectReward. + */ +export const zProjectRewardClaimRewardClaimCreationDtoJsonld = z.object({ + charge: z.string(), + reward: z.string(), + shippingAddress: zShippingAddressJsonld.nullish() +}); + +/** + * A ProjectRewardClaim represents the will of an User who wishes to obtain one ProjectReward.\ + * \ + * Once created ProjectRewardClaims can only be deleted by the User who owns it, + * while their status can only be updated by the User who owns the Project of the claimed ProjectReward. + */ +export const zProjectRewardClaimJsonld = z.object({ + '@context': z.union([ + z.string(), + z.object({ + '@vocab': z.string(), + hydra: z.enum(['http://www.w3.org/ns/hydra/core#']) + }) + ]).optional(), + '@id': z.string().readonly().optional(), + '@type': z.string().readonly().optional(), + id: z.int().readonly().optional(), + owner: z.string().optional(), + charge: z.string().optional(), + reward: z.string().optional(), + status: z.enum(['in_pending', 'fulfilled']).optional(), + shippingAddress: zShippingAddressJsonld.nullish() +}); + export const zTerritory = z.object({ country: z.string().nullable(), subLvl1: z.string().nullish(), @@ -1205,6 +1295,21 @@ export const zUserUserSignupDtoJsonld = z.object({ type: z.enum(['individual', 'organization']).default('individual') }); +/** + * Users represent people who interact with the platform. + */ +export const zUserUserUpdationDto = z.object({ + handle: z.string().min(4).max(30).regex(/^([a-z0-9_]+)$/).optional(), + email: z.email().optional(), + password: z.string().min(8).optional(), + avatar: z.url().optional(), + type: z.enum(['individual', 'organization']).optional(), + roles: z.array(z.string()).optional(), + links: z.array(z.string()).optional(), + territory: zTerritory.optional(), + description: z.string().optional() +}); + /** * Users represent people who interact with the platform. */ @@ -1641,6 +1746,7 @@ export const zProject = z.object({ rewards: z.array(z.string()).readonly().optional(), budget: zBudget.readonly().optional(), budgetItems: z.array(z.string()).readonly().optional(), + collaborations: z.array(z.string()).readonly().optional(), updates: z.array(z.string()).readonly().optional(), matchCallSubmissions: z.array(z.string()).readonly().optional(), links: z.array(zLink).readonly().optional(), @@ -1701,6 +1807,7 @@ export const zProjectJsonld = z.object({ rewards: z.array(z.string()).readonly().optional(), budget: zBudgetJsonld.readonly().optional(), budgetItems: z.array(z.string()).readonly().optional(), + collaborations: z.array(z.string()).readonly().optional(), updates: z.array(z.string()).readonly().optional(), matchCallSubmissions: z.array(z.string()).readonly().optional(), links: z.array(zLinkJsonld).readonly().optional(), @@ -2405,7 +2512,12 @@ export const zApiProjectRewardClaimsGetCollectionQuery = z.object({ charge: z.string().optional(), 'charge[]': z.array(z.string()).optional(), reward: z.string().optional(), - 'reward[]': z.array(z.string()).optional() + 'reward[]': z.array(z.string()).optional(), + status: z.string().optional(), + 'status[]': z.array(z.string()).optional(), + 'shippingAddress.city': z.string().optional(), + 'shippingAddress.postCode': z.string().optional(), + 'shippingAddress.country': z.string().optional() }); /** @@ -2441,6 +2553,20 @@ export const zApiProjectRewardClaimsIdGetPath = z.object({ */ export const zApiProjectRewardClaimsIdGetResponse = zProjectRewardClaim; +/** + * The updated ProjectRewardClaim resource + */ +export const zApiProjectRewardClaimsIdPatchBody = zProjectRewardClaimRewardClaimUpdationDto; + +export const zApiProjectRewardClaimsIdPatchPath = z.object({ + id: z.string() +}); + +/** + * ProjectRewardClaim resource updated + */ +export const zApiProjectRewardClaimsIdPatchResponse = zProjectRewardClaim; + export const zApiProjectSupportsmoneyTotalGetCollectionQuery = z.object({ project: z.string().optional(), 'project[]': z.array(z.string()).optional(), @@ -2649,7 +2775,7 @@ export const zApiUsersIdDeleteResponse = z.void(); /** * The updated User resource */ -export const zApiUsersIdPatchBody = zUser; +export const zApiUsersIdPatchBody = zUserUserUpdationDto; export const zApiUsersIdPatchPath = z.object({ id: z.string() diff --git a/src/pages/[...locale]/admin/home/hero.astro b/src/pages/[...locale]/admin/home/hero.astro index fbad3e16..52801202 100644 --- a/src/pages/[...locale]/admin/home/hero.astro +++ b/src/pages/[...locale]/admin/home/hero.astro @@ -9,16 +9,14 @@ import { SUBSECTIONS } from "./subsections"; const { t, lang, session } = Astro.locals; const activePath = "/admin/home/hero"; -// The form is pre-filled with the live hero so the admin edits from what is -// currently published instead of from a blank block. -const hero = await homeHeroRepository.getActive(); +// The list backs the history tab; the editor itself always starts blank (new). const heroes = await homeHeroRepository.getAll(); --- - + diff --git a/src/pages/[...locale]/admin/home/highlights.astro b/src/pages/[...locale]/admin/home/highlights.astro index d4d7c539..91b9a1ca 100644 --- a/src/pages/[...locale]/admin/home/highlights.astro +++ b/src/pages/[...locale]/admin/home/highlights.astro @@ -3,6 +3,7 @@ import Layout from "../../../../layouts/Layout.astro"; import App from "../../../../layouts/App.svelte"; import WithSidebar from "../../../../layouts/WithSidebar.svelte"; import Highlights from "../../../../components/admin/home/Highlights.svelte"; +import { apiProjectsIdOrSlugGet } from "../../../../openapi/client/sdk.gen"; import { highlightRepository } from "../../../../repositories/highlights"; import { SUBSECTIONS } from "./subsections"; @@ -18,7 +19,21 @@ const config = { layout: data.highlight.layout, } : null, - slots: data ? data.slots.map((s) => ({ position: s.position, projectId: s.projectId })) : [], + slots: await Promise.all( + (data?.slots ?? []).map(async (s) => { + const { data: project } = await apiProjectsIdOrSlugGet({ + path: { idOrSlug: String(s.projectId) }, + headers: { "Accept-Language": lang }, + }); + + return { + position: s.position, + projectId: s.projectId, + projectTitle: project?.title ?? String(s.projectId), + projectImage: project?.video?.thumbnail ?? project?.cover ?? null, + }; + }), + ), }; --- diff --git a/src/pages/[...locale]/admin/reviews/projects.astro b/src/pages/[...locale]/admin/reviews/projects.astro new file mode 100644 index 00000000..5118a7c3 --- /dev/null +++ b/src/pages/[...locale]/admin/reviews/projects.astro @@ -0,0 +1,16 @@ +--- +import Layout from "../../../../layouts/Layout.astro"; +import App from "../../../../layouts/App.svelte"; +import ReviewProjects from "../../../../components/admin/reviews/ReviewProjects.svelte"; + +const { t, lang, session } = Astro.locals; +--- + + + + + + diff --git a/src/pages/[...locale]/checkout/_VerificationSuccess.astro b/src/pages/[...locale]/checkout/_VerificationSuccess.astro index 8e4e25c1..48cfcc9c 100644 --- a/src/pages/[...locale]/checkout/_VerificationSuccess.astro +++ b/src/pages/[...locale]/checkout/_VerificationSuccess.astro @@ -1,12 +1,13 @@ --- import FeedbackForm from "../../../components/checkout/FeedbackForm.svelte"; +import RewardClaims from "../../../components/checkout/RewardClaims.svelte"; import Summary from "../../../components/checkout/Summary.svelte"; import Card from "../../../components/library/cards/Card.svelte"; -import ClearCart from "../../../components/cart/ClearCart.svelte"; const { t, session } = Astro.locals; -const { total, selectedGateway, donatedProjects, projectSupports, postPaymentUrl } = Astro.props; +const { total, selectedGateway, donatedProjects, projectSupports, postPaymentUrl, checkoutId } = + Astro.props; ---
    @@ -45,7 +46,7 @@ const { total, selectedGateway, donatedProjects, projectSupports, postPaymentUrl class="lg: sticky -bottom-6 flex h-fit w-full flex-col items-start gap-6 rounded-3xl border-black pr-4 pb-10 pl-4 lg:top-(--sticky-top) lg:bottom-auto lg:w-1/2 lg:gap-6 lg:rounded-[40px] lg:p-6" > - +

    diff --git a/src/pages/[...locale]/checkout/verify.astro b/src/pages/[...locale]/checkout/verify.astro index 8dfe042f..87f3afb1 100644 --- a/src/pages/[...locale]/checkout/verify.astro +++ b/src/pages/[...locale]/checkout/verify.astro @@ -18,7 +18,13 @@ import { extractId } from "../../../utils/extractId"; import { sumMoney } from "../../../utils/money"; import { goto } from "../../../utils/navigation"; -import type { Money, MoneyOutput, Project, ProjectSupport } from "../../../openapi/client"; +import type { + GatewayCharge, + Money, + MoneyOutput, + Project, + ProjectSupport, +} from "../../../openapi/client"; const { t, lang, session } = Astro.locals; @@ -50,6 +56,7 @@ let total: Money | undefined; let selectedGateway: string | undefined; let donatedProjects: Project[] = []; let projectSupports: ProjectSupport[] = []; +let charges: GatewayCharge[] = []; try { if (!checkoutId) { @@ -71,7 +78,7 @@ try { selectedGateway = checkout.gateway.split("/").at(-1); - const charges = checkout.charges ?? []; + charges = checkout.charges ?? []; const money = charges .map((charge) => charge.money) @@ -138,6 +145,7 @@ try { donatedProjects={donatedProjects} projectSupports={projectSupports} postPaymentUrl={postPaymentUrl} + {checkoutId} /> ) } diff --git a/src/pages/[...locale]/index.astro b/src/pages/[...locale]/index.astro index a956c1cf..9632e1fd 100644 --- a/src/pages/[...locale]/index.astro +++ b/src/pages/[...locale]/index.astro @@ -26,23 +26,23 @@ if (locale && !isSupportedLocale(locale)) { const { t, lang } = Astro.locals; const activeBanners = await bannerRepository.getActive(); -const hero = await homeHeroRepository.getActive(); +const hero = await homeHeroRepository.getActive(lang); // Authored in the admin as Markdown. Only ROLE_ADMIN can write it. const heroContent = hero ? await renderMarkdown(hero.content) : null; --- - + - - - + + + - + diff --git a/src/pages/[...locale]/login/index.astro b/src/pages/[...locale]/login/index.astro index 89aac93f..69619cb8 100644 --- a/src/pages/[...locale]/login/index.astro +++ b/src/pages/[...locale]/login/index.astro @@ -1,6 +1,6 @@ --- import CSRF from "csrf"; -import { getSession, isExpired } from "../../../auth/session"; +import { isExpired } from "../../../auth/session"; const wantsCallback = Astro.url.searchParams.get("callback"); if (wantsCallback) { @@ -8,7 +8,7 @@ if (wantsCallback) { Astro.cookies.set("callback", wantsCallback, { path: "/" }); } -const session = await getSession(Astro.cookies); +const { session } = Astro.locals; if (session && !isExpired(session)) { return Astro.redirect(wantsCallback || "/"); } diff --git a/src/pages/[...locale]/me/index.astro b/src/pages/[...locale]/me/index.astro index 7364d5ba..aabc24e1 100644 --- a/src/pages/[...locale]/me/index.astro +++ b/src/pages/[...locale]/me/index.astro @@ -22,7 +22,7 @@ const period = Astro.url.searchParams.get("period") || new Date().getFullYear(). --- - +

    @@ -38,7 +38,7 @@ const period = Astro.url.searchParams.get("period") || new Date().getFullYear().
    - +
    diff --git a/src/pages/[...locale]/me/manage/index.astro b/src/pages/[...locale]/me/manage/index.astro index d3e249b7..3cad5261 100644 --- a/src/pages/[...locale]/me/manage/index.astro +++ b/src/pages/[...locale]/me/manage/index.astro @@ -2,21 +2,40 @@ import Layout from "../../../../layouts/Layout.astro"; import App from "../../../../layouts/App.svelte"; import WithSidebar from "../../../../layouts/WithSidebar.svelte"; +import ManagePersonalProfile from "../../../../components/profile/manage/ManagePersonalProfile.svelte"; +import { PROFILE_NAV_ITEMS, PROFILE_PATHS } from "../../../../components/profile/meSections"; +import { setSession } from "../../../../auth/session"; +import { apiUsersIdOrHandleGet, apiUsersIdpersonGet } from "../../../../openapi/client"; import { goto } from "../../../../utils/navigation"; const { lang, t, session } = Astro.locals; if (!session) { - return goto("/login", { query: { callback: "/me/manage" } }); + return goto("/login", { query: { callback: PROFILE_PATHS.root } }); } -const activePath = "/me/manage"; +const activePath = PROFILE_PATHS.root; +const navItems = PROFILE_NAV_ITEMS; + +const path = { id: String(session.user.id) }; +const headers = session.token.asHttpHeaders; + +// The session cookie holds a snapshot of the User taken at login, read the current one +const [{ data: user }, { data: person }] = await Promise.all([ + apiUsersIdOrHandleGet({ path: { idOrHandle: path.id }, headers }), + apiUsersIdpersonGet({ path, headers }), +]); + +// Refresh the cookie too: the names feed the display name shown in the header +if (user) { + setSession(Astro.cookies, { ...session, user }); +} --- - - - -
    + + + + diff --git a/src/pages/[...locale]/me/manage/preferences.astro b/src/pages/[...locale]/me/manage/preferences.astro new file mode 100644 index 00000000..8061763f --- /dev/null +++ b/src/pages/[...locale]/me/manage/preferences.astro @@ -0,0 +1,38 @@ +--- +import Layout from "../../../../layouts/Layout.astro"; +import App from "../../../../layouts/App.svelte"; +import WithSidebar from "../../../../layouts/WithSidebar.svelte"; +import ManagePreferencesSection from "../../../../components/profile/ManagePreferencesSection.svelte"; +import { PROFILE_NAV_ITEMS, PROFILE_PATHS } from "../../../../components/profile/meSections"; +import { apiCategoriesGetCollection } from "../../../../openapi/client"; +import { withoutCache } from "../../../../openapi/cacheInterceptor"; +import { toCollectionItems } from "../../../../utils/hydra"; +import { goto } from "../../../../utils/navigation"; + +import type { Category } from "../../../../openapi/client"; + +const { lang, langs, t, session } = Astro.locals; + +if (!session) { + return goto("/login", { query: { callback: PROFILE_PATHS.preferences } }); +} + +const activePath = PROFILE_PATHS.preferences; +const navItems = PROFILE_NAV_ITEMS; + +// The Categories behind "Proyectos que te motivan", already translated by `Accept-Language` +const { data: categoriesRaw } = await withoutCache(() => + apiCategoriesGetCollection({ + headers: { "Accept-Language": langs }, + }), +); +const categories = toCollectionItems(categoriesRaw); +--- + + + + + + + + diff --git a/src/pages/[...locale]/me/manage/profile/index.astro b/src/pages/[...locale]/me/manage/profile/index.astro new file mode 100644 index 00000000..5c56c02d --- /dev/null +++ b/src/pages/[...locale]/me/manage/profile/index.astro @@ -0,0 +1,49 @@ +--- +import Layout from "../../../../../layouts/Layout.astro"; +import App from "../../../../../layouts/App.svelte"; +import WithSidebar from "../../../../../layouts/WithSidebar.svelte"; +import ManagePublicProfile from "../../../../../components/profile/manage/ManagePublicProfile.svelte"; +import { PROFILE_NAV_ITEMS, PROFILE_PATHS } from "../../../../../components/profile/meSections"; +import { setSession } from "../../../../../auth/session"; +import { + apiUsersIdOrHandleGet, + apiUsersIdorganizationGet, + apiUsersIdpersonGet, +} from "../../../../../openapi/client"; +import { goto } from "../../../../../utils/navigation"; + +const { lang, t, session } = Astro.locals; + +if (!session) { + return goto("/login", { query: { callback: PROFILE_PATHS.profile } }); +} + +const activePath = PROFILE_PATHS.profile; +const navItems = PROFILE_NAV_ITEMS; + +const path = { id: String(session.user.id) }; +const headers = session.token.asHttpHeaders; + +// The session cookie holds a snapshot of the User taken at login, read the current one +const [{ data: user }, { data: person }, { data: organization }] = await Promise.all([ + apiUsersIdOrHandleGet({ path: { idOrHandle: path.id }, headers }), + apiUsersIdpersonGet({ path, headers }), + // 404 for individual users, resolved as `undefined` + session.user.type === "organization" + ? apiUsersIdorganizationGet({ path, headers }) + : Promise.resolve({ data: undefined }), +]); + +// Refresh the cookie too: `updatePublicProfile` builds the new links list from `session.user` +if (user) { + setSession(Astro.cookies, { ...session, user }); +} +--- + + + + + + + + diff --git a/src/pages/[...locale]/project/[idOrSlug]/manage/index.astro b/src/pages/[...locale]/project/[idOrSlug]/manage/index.astro new file mode 100644 index 00000000..34a0e570 --- /dev/null +++ b/src/pages/[...locale]/project/[idOrSlug]/manage/index.astro @@ -0,0 +1,62 @@ +--- +import ProjectManage from "../../../../../components/project/manage/ProjectManage.svelte"; +import App from "../../../../../layouts/App.svelte"; +import Layout from "../../../../../layouts/Layout.astro"; +import { apiProjectsIdOrSlugGet } from "../../../../../openapi/client"; +import { client } from "../../../../../openapi/client/client.gen"; +import { apiUsersIdOrHandleGetUrl } from "../../../../../openapi/client/operation-paths.gen"; +import { goto } from "../../../../../utils/navigation"; +import { NotFound, Unauthorized } from "../../../../../utils/responses"; + +const { t, lang, session } = Astro.locals; + +const { idOrSlug } = Astro.params; +if (!idOrSlug) { + return Astro.rewrite("/500"); +} + +const requestUrl = new URL(Astro.request.url); +const toLogin = () => + goto("/login", { query: { callback: requestUrl.pathname + requestUrl.search } }); + +if (!session) { + return toLogin(); +} + +const { data: project, error } = await apiProjectsIdOrSlugGet({ + headers: { ...session.token.asHttpHeaders, "Accept-Language": lang }, + path: { idOrSlug }, +}); + +if (error) { + if (error.status === NotFound.status) { + return Astro.rewrite("/404"); + } + + // Stale/revoked token: the API rejects it with 401 "Bad credentials." + if (error.status === Unauthorized.status) { + return toLogin(); + } + + console.error({ project, error }); + return Astro.rewrite("/500"); +} + +const isAdmin = session.user.roles?.includes("ROLE_ADMIN"); +// Relative IRI, as the API returns it in `project.owner` +const ownerIri = client.buildUrl({ + baseUrl: "", + url: apiUsersIdOrHandleGetUrl, + path: { idOrHandle: session.user.id }, +}); +const isOwner = project.owner === ownerIri; +if (!isAdmin && !isOwner) { + return Astro.rewrite("/403"); +} +--- + + + + + + diff --git a/src/pages/[...locale]/reviews/[id]/[areaId]/index.astro b/src/pages/[...locale]/reviews/[id]/[areaId]/index.astro new file mode 100644 index 00000000..e102ca5d --- /dev/null +++ b/src/pages/[...locale]/reviews/[id]/[areaId]/index.astro @@ -0,0 +1,68 @@ +--- +import Layout from "../../../../../layouts/Layout.astro"; +import App from "../../../../../layouts/App.svelte"; +import ReviewChat from "../../../../../components/review/ReviewChat.svelte"; +import { + canAccessReview, + isConsultant, + sessionUserIri, +} from "../../../../../services/reviewAccess"; +import { getReview, getReviewArea, getReviewAuthors } from "../../../../../services/projectReview"; +import { getReviewedProject } from "../../../../../services/reviewProject"; +import { goto } from "../../../../../utils/navigation"; + +const { t, lang, session } = Astro.locals; + +const { id, areaId } = Astro.params; + +if (!session) { + const requestUrl = new URL(Astro.request.url); + return goto("/login", { query: { callback: requestUrl.pathname + requestUrl.search } }); +} + +if (!id || !/^\d+$/.test(id) || !areaId || !/^\d+$/.test(areaId)) { + return Astro.rewrite("/404"); +} + +const { data: review } = getReview(id); + +if (!review) { + return Astro.rewrite("/404"); +} + +// Only the consultant admin and the promoter owning the reviewed project get in. +if (!(await canAccessReview(session, review))) { + return Astro.rewrite("/403"); +} + +const { data: area } = getReviewArea(id, areaId); + +if (!area) { + return Astro.rewrite("/404"); +} + +const { data: authors } = getReviewAuthors(id); + +const project = await getReviewedProject(review, session, lang); + +// Only the consultant admin may assess risks from here, same as on the areas screen. +const isReviewer = isConsultant(session); +--- + + + + + + diff --git a/src/pages/[...locale]/reviews/[id]/index.astro b/src/pages/[...locale]/reviews/[id]/index.astro new file mode 100644 index 00000000..061c88b1 --- /dev/null +++ b/src/pages/[...locale]/reviews/[id]/index.astro @@ -0,0 +1,79 @@ +--- +import Layout from "../../../../layouts/Layout.astro"; +import App from "../../../../layouts/App.svelte"; +import ReviewAreas from "../../../../components/review/ReviewAreas.svelte"; +import { canAccessReview, isConsultant } from "../../../../services/reviewAccess"; +import { getReview } from "../../../../services/projectReview"; +import { getReviewedProject } from "../../../../services/reviewProject"; +import { goto } from "../../../../utils/navigation"; + +const { t, lang, session } = Astro.locals; + +const { id } = Astro.params; + +if (!session) { + const requestUrl = new URL(Astro.request.url); + return goto("/login", { query: { callback: requestUrl.pathname + requestUrl.search } }); +} + +if (!id || !/^\d+$/.test(id)) { + return Astro.rewrite("/404"); +} + +const { data: review } = getReview(id); + +if (!review) { + return Astro.rewrite("/404"); +} + +// Only the consultant admin and the promoter owning the reviewed project get in. +if (!(await canAccessReview(session, review))) { + return Astro.rewrite("/403"); +} + +const project = await getReviewedProject(review, session, lang); + +const isReviewer = isConsultant(session); + +// Each side reads the other one first: the consultant is named for the promoter and +// the promoter for the consultant. Without one of them the copy falls back to a +// version with no hole in it. +const counterpart = isReviewer ? project?.promoter : project?.reviewer; + +const title = counterpart?.handle + ? t( + isReviewer ? "pages.review.titleReviewer" : "pages.review.titlePromoter", + isReviewer + ? { promoter: counterpart.handle, project: project?.title ?? "" } + : { reviewer: counterpart.handle, project: project?.title ?? "" }, + ) + : t("pages.review.titleFallback", { project: project?.title ?? "" }); + +const subtitle = counterpart?.name + ? t( + isReviewer ? "pages.review.subtitleReviewer" : "pages.review.subtitlePromoter", + isReviewer ? { promoter: counterpart.name } : { reviewer: counterpart.name }, + ) + : t("pages.review.subtitleFallback"); +--- + + + +
    + +
    +
    +
    diff --git a/src/pages/[...locale]/search.astro b/src/pages/[...locale]/search.astro index f4290010..14bebf44 100644 --- a/src/pages/[...locale]/search.astro +++ b/src/pages/[...locale]/search.astro @@ -1,22 +1,12 @@ --- import Layout from "../../layouts/Layout.astro"; import App from "../../layouts/App.svelte"; -import SearchFilters from "../../components/search/SearchFilters.svelte"; -import SearchResultsClient from "../../components/search/SearchResultsClient.svelte"; -import { parseSearchParamsFilters } from "../../stores/searchStore"; - -import type { Project } from "../../openapi/client/types.gen"; +import Search from "../../components/search/Search.svelte"; +import { parseSearchParamsFilters } from "../../utils/searchParams"; const { t, lang } = Astro.locals; -// Initial projects will be fetched client-side to avoid blocking SSR -const initialProjects: Project[] = []; - -const url = new URL(Astro.request.url); -const initialFilters = parseSearchParamsFilters(url.searchParams); - -// Check if URL has search parameters -const hasInitialSearch = Object.entries(initialFilters).length > 0; +const initialFilters = parseSearchParamsFilters(new URL(Astro.request.url).searchParams); --- @@ -32,20 +22,7 @@ const hasInitialSearch = Object.entries(initialFilters).length > 0;

    - -
    - -
    - - -
    - -
    + diff --git a/src/pages/[...locale]/static/[fileName].astro b/src/pages/[...locale]/static/[fileName].astro index ba40761b..d0007676 100644 --- a/src/pages/[...locale]/static/[fileName].astro +++ b/src/pages/[...locale]/static/[fileName].astro @@ -20,7 +20,7 @@ const file = await markdownFiles[fileKey](); const Content = file.default; --- - + diff --git a/src/pages/[...locale]/user/[idOrHandle]/index.astro b/src/pages/[...locale]/user/[idOrHandle]/index.astro index cd14d3da..e75b1ed6 100644 --- a/src/pages/[...locale]/user/[idOrHandle]/index.astro +++ b/src/pages/[...locale]/user/[idOrHandle]/index.astro @@ -8,10 +8,20 @@ import { apiProjectSupportsmoneyTotalGetCollection, apiProjectsGetCollection, } from "../../../../openapi/client"; -import type { Money, ProjectSupport } from "../../../../openapi/client"; -import { apiProjectSupportsGetCollectionUrl } from "../../../../openapi/client/operation-paths.gen"; +import type { + ApiProjectsGetCollectionData, + Money, + Project, + ProjectSupport, +} from "../../../../openapi/client"; +import { + apiProjectSupportsGetCollectionUrl, + apiUsersIdOrHandleGetUrl, +} from "../../../../openapi/client/operation-paths.gen"; import type { Campaign } from "../../../../types/campaign"; import { client } from "../../../../openapi/client/client.gen"; +import { toCollectionItems } from "../../../../utils/hydra"; +import { ALL_PUBLIC_STATUSES, expandPublicStatuses } from "../../../../utils/projectStatus"; const { idOrHandle } = Astro.params; const { lang } = Astro.locals; @@ -74,39 +84,46 @@ const slugs = userSupports .map((s) => s.project?.split("/").at(-1)) .filter(Boolean) as string[]; -let campaigns: Campaign[] = []; - -if (slugs.length > 0) { +async function fetchCampaigns(query: ApiProjectsGetCollectionData["query"]): Promise { try { - const { data: projects } = await apiProjectsGetCollection({ - query: { "slug[]": slugs }, + const { data } = await apiProjectsGetCollection({ + query, headers: { "Accept-Language": lang }, }); - if (projects) { - campaigns = projects - .map((project) => ({ - ...project, - slug: project.slug!, - title: project.title!, - image: project.video?.thumbnail ?? "", - minimum: project.budget?.minimum?.money as Money, - optimum: project.budget?.optimum?.money as Money | undefined, - size: "small", - })) - .filter((c) => c.slug && c.title) as Campaign[]; - } + return toCollectionItems(data) + .map((project) => ({ + ...project, + slug: project.slug!, + title: project.title!, + image: project.cover || project.video?.thumbnail || "", + minimum: project.budget?.minimum?.money as Money, + optimum: project.budget?.optimum?.money as Money | undefined, + size: "small", + })) + .filter((c) => c.slug && c.title) as Campaign[]; } catch (e) { console.error("Error fetching projects:", e); + return []; } } + +const [donatedCampaigns, ownedCampaigns] = await Promise.all([ + slugs.length ? fetchCampaigns({ "slug[]": slugs }) : [], + fetchCampaigns({ + owner: apiUsersIdOrHandleGetUrl.replace("{idOrHandle}", String(user.id)), + "status[]": expandPublicStatuses(ALL_PUBLIC_STATUSES), + itemsPerPage: 10, + }), +]); --- { + const { session } = locals; + if (!session) return Unauthorized; + + const { checkoutId, items } = (await request.json()) as { + checkoutId?: string; + items?: CheckoutItem[]; + }; + + if (!checkoutId || !Array.isArray(items)) { + return json({ error: 'Missing "checkoutId" or "items" in request body' }, 400); + } + + const headers = session.token.asHttpHeaders; + + const { data: checkout, error: checkoutError } = await apiGatewayCheckoutsIdGet({ + path: { id: checkoutId }, + headers, + }); + + if (checkoutError || !checkout) { + console.error("[RewardClaims] failed to fetch checkout", checkoutError); + return json({ error: "Could not fetch checkout" }, 500); + } + + const charges = checkout.charges ?? []; + if (charges.length === 0) { + return json({ error: "Checkout has no charges" }, 422); + } + + const { claims, unmatched } = pairRewardsToCharges(items, charges); + + if (unmatched.length > 0) { + // After field matching, the only way to have pending cart items without a + // matching API charge is a failure in the matching itself. Log the input + // data so it can be refined rather than guessing an association. + console.error("[RewardClaims] no matching charge for cart items:", { + unmatched, + availableCharges: charges, + }); + } + + const result = { created: 0, alreadyExists: 0, failed: 0, unmatched: unmatched.length }; + + for (const claim of claims) { + try { + const { error } = await apiProjectRewardClaimsPost({ headers, body: claim }); + + if (!error) { + result.created++; + continue; + } + } catch (err) { + console.error("[RewardClaims] claim POST failed:", claim, err); + } + + // A claim that already exists for the same charge+reward pair means the + // previous run got there first. That is the outcome we want, so it counts + // as a success — it is what makes re-trials of partially claimed carts safe. + if (await claimExists(headers, claim)) { + result.alreadyExists++; + } else { + result.failed++; + console.error("[RewardClaims] claim not created:", claim); + } + } + + return json(result); +}; + +/** + * Pairs each cart item with the GatewayCharge that paid for it. + * + * The v4 payment API has no way to know that a charge pays for a Reward, so + * the association must be recovered from the local cart. The charges come from + * the checkout fetched and validated in `verify.astro`, so they carry the same + * fields the cart item was created with. + */ +function pairRewardsToCharges( + items: CheckoutItem[], + charges: GatewayCharge[], +): { claims: Array<{ reward: string; charge: string }>; unmatched: CheckoutItem[] } { + const usedCharges = new Set(); + const claims: Array<{ reward: string; charge: string }> = []; + const unmatched: CheckoutItem[] = []; + + for (const item of items) { + if (item.kind !== "reward" || item.reward?.id == null) continue; + + const match = charges.find( + (charge) => + !usedCharges.has(charge) && + charge.target === item.target && + charge.title === item.title && + charge.type === item.type && + charge.money.amount === item.money.amount && + charge.money.currency === item.money.currency, + ); + + if (!match) { + unmatched.push(item); + continue; + } + + usedCharges.add(match); + claims.push({ + reward: client.buildUrl({ + url: apiProjectRewardsIdGetUrl, + path: { id: item.reward.id }, + }), + charge: client.buildUrl({ + url: apiGatewayChargesIdGetUrl, + path: { id: match.id }, + }), + }); + } + + return { claims, unmatched }; +} + +async function claimExists( + headers: Record | HeadersInit | undefined, + claim: { reward: string; charge: string }, +): Promise { + const { data } = await apiProjectRewardClaimsGetCollection({ + headers, + query: { charge: claim.charge, reward: claim.reward, itemsPerPage: 1 }, + }); + + return (data?.length ?? 0) > 0; +} + +function json(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/src/repositories/highlights.ts b/src/repositories/highlights.ts index 2f40cbfd..9d0931be 100644 --- a/src/repositories/highlights.ts +++ b/src/repositories/highlights.ts @@ -65,7 +65,7 @@ class HighlightRepository { id, highlight_id AS highlightId, position, - project_id AS projectId + CAST(project_id AS INTEGER) AS projectId FROM highlight_slots WHERE highlight_id = ? ORDER BY position ASC`, diff --git a/src/repositories/homeHero.ts b/src/repositories/homeHero.ts index f5377c94..48133675 100644 --- a/src/repositories/homeHero.ts +++ b/src/repositories/homeHero.ts @@ -1,7 +1,39 @@ import { env } from "cloudflare:workers"; +import { + HOME_HERO_COLUMN_TO_PROPERTY, + HOME_HERO_TABLE, + HOME_HERO_TRANSLATABLE_COLUMNS, + translationRepository, +} from "./translations"; + +/** + * The stored translations of one hero row, keyed by locale. Only the columns a + * locale actually translates are present. Populated by `getAll()` and used to + * re-open the editor and to preview a language without merging into the row. + */ +export type HeroTranslations = Record< + string, + Partial< + Pick< + HomeHeroRecord, + | "title" + | "content" + | "primaryCtaText" + | "primaryCtaLink" + | "secondaryCtaText" + | "secondaryCtaLink" + > + > +>; + export interface HomeHeroRecord { id: number; + /** + * The locale the base row is written in. Translation rows in the + * `translations` table cover the remaining locales. + */ + language: string; title: string; content: string; primaryCtaText: string | null; @@ -16,9 +48,20 @@ export interface HomeHeroRecord { mediaType: string | null; startsAt: Date; dateCreated: Date; + /** + * Locale codes the hero content is available in: the base (default) locale + * plus every locale with a stored translation row. Populated by `getAll()`. + */ + languages?: string[]; + /** + * Stored translations per locale. Populated by `getAll()` — the editor + * seeds its per-language fields from here when a stored block is edited. + */ + translations?: HeroTranslations; } const COLUMNS = `id, + language, title, content, primary_cta_text AS primaryCtaText, @@ -51,7 +94,9 @@ class HomeHeroRepository { } /** - * Every hero ever scheduled, most recently authored first. + * Every hero ever scheduled, most recently authored first. Each row carries + * the `languages` it is available in: the base `language` plus every locale + * with a stored translation. * @returns All stored heroes */ public async getAll(): Promise { @@ -63,14 +108,63 @@ class HomeHeroRepository { ) .all(); - return results.map(fromRow); + const heroes = results.map(fromRow); + + const { results: translationRows } = await this.db + .prepare( + `SELECT row_id AS rowId, locale, column_name AS columnName, value + FROM translations + WHERE table_name = ?`, + ) + .bind(HOME_HERO_TABLE) + .all<{ rowId: number; locale: string; columnName: string; value: string }>(); + + const translatedByRow: Record = {}; + + for (const translation of translationRows) { + const property = + HOME_HERO_COLUMN_TO_PROPERTY[ + translation.columnName as (typeof HOME_HERO_TRANSLATABLE_COLUMNS)[number] + ]; + + if (!property) { + continue; + } + + translatedByRow[translation.rowId] ??= {}; + translatedByRow[translation.rowId][translation.locale] ??= {}; + translatedByRow[translation.rowId][translation.locale][ + property as keyof NonNullable + ] = translation.value; + } + + const defaultLocale = import.meta.env.PUBLIC_DEFAULT_LANGUAGE; + + for (const hero of heroes) { + const baseLocale = hero.language || defaultLocale; + const translations = translatedByRow[hero.id] ?? {}; + const translationLocales = Object.keys(translations); + + hero.languages = [ + baseLocale, + ...new Set(translationLocales.filter((locale) => locale !== baseLocale)), + ]; + + if (translationLocales.length > 0) { + hero.translations = translations; + } + } + + return heroes; } /** * The hero whose start date has already passed, most recently authored first. + * @param locale When given and different from the default locale, the returned + * hero has its stored translations for that locale merged on top of the base row. * @returns The active hero, or null when nothing is scheduled yet */ - public async getActive(): Promise { + public async getActive(locale?: string): Promise { const now = Date.now(); const row = await this.db @@ -84,17 +178,49 @@ class HomeHeroRepository { .bind(now) .first(); - return row ? fromRow(row) : null; + if (!row) { + return null; + } + + const hero = fromRow(row); + + const baseLocale = hero.language || import.meta.env.PUBLIC_DEFAULT_LANGUAGE; + + if (!locale || locale === baseLocale) { + return hero; + } + + const overrides = await translationRepository.get( + HOME_HERO_TABLE, + hero.id, + locale, + HOME_HERO_TRANSLATABLE_COLUMNS, + ); + + for (const [column, value] of Object.entries(overrides)) { + if (!value) continue; + + const property = + HOME_HERO_COLUMN_TO_PROPERTY[ + column as (typeof HOME_HERO_TRANSLATABLE_COLUMNS)[number] + ]; + + hero[property as keyof HomeHeroRecord] = value as never; + } + + return hero; } /** * Store a new hero block. * @param hero The hero to store + * @returns The id of the newly created hero */ - public async create(hero: Omit): Promise { + public async create(hero: Omit): Promise { const result = await this.db .prepare( `INSERT INTO home_hero ( + language, title, content, primary_cta_text, @@ -105,9 +231,10 @@ class HomeHeroRepository { media_type, starts_at, date_created - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .bind( + hero.language, hero.title, hero.content, hero.primaryCtaText, @@ -124,6 +251,60 @@ class HomeHeroRepository { if (result.error) { throw new Error(result.error); } + + return Number(result.meta.last_row_id); + } + + /** + * Overwrite an existing hero block. + * @param id The hero to overwrite + * @param hero The new values + */ + public async update( + id: number, + hero: Pick< + HomeHeroRecord, + | "language" + | "title" + | "content" + | "primaryCtaText" + | "primaryCtaLink" + | "secondaryCtaText" + | "secondaryCtaLink" + | "mediaUrl" + | "mediaType" + | "startsAt" + >, + ): Promise { + await this.db + .prepare( + `UPDATE home_hero SET + language = ?, + title = ?, + content = ?, + primary_cta_text = ?, + primary_cta_link = ?, + secondary_cta_text = ?, + secondary_cta_link = ?, + media_url = ?, + media_type = ?, + starts_at = ? + WHERE id = ?`, + ) + .bind( + hero.language, + hero.title, + hero.content, + hero.primaryCtaText, + hero.primaryCtaLink, + hero.secondaryCtaText, + hero.secondaryCtaLink, + hero.mediaUrl, + hero.mediaType, + hero.startsAt.getTime(), + id, + ) + .run(); } /** diff --git a/src/repositories/translations.ts b/src/repositories/translations.ts new file mode 100644 index 00000000..d779999d --- /dev/null +++ b/src/repositories/translations.ts @@ -0,0 +1,102 @@ +import { env } from "cloudflare:workers"; + +/** The home_hero columns that may be translated. `column_name` = DB column. */ +export const HOME_HERO_TABLE = "home_hero"; + +export const HOME_HERO_TRANSLATABLE_COLUMNS = [ + "title", + "content", + "primary_cta_text", + "primary_cta_link", + "secondary_cta_text", + "secondary_cta_link", +] as const; + +/** Maps a DB column back to the camelCase property of `HomeHeroRecord`. */ +export const HOME_HERO_COLUMN_TO_PROPERTY: Record< + (typeof HOME_HERO_TRANSLATABLE_COLUMNS)[number], + string +> = { + title: "title", + content: "content", + primary_cta_text: "primaryCtaText", + primary_cta_link: "primaryCtaLink", + secondary_cta_text: "secondaryCtaText", + secondary_cta_link: "secondaryCtaLink", +}; + +class TranslationRepository { + db: D1Database; + + constructor(db: D1Database) { + this.db = db; + } + + /** + * The stored translations of one row for one locale. + * @returns A map of DB column name to translated value + */ + public async get( + tableName: string, + rowId: number, + locale: string, + columns: readonly string[], + ): Promise> { + if (columns.length === 0) { + return {}; + } + + const placeholders = columns.map(() => "?").join(", "); + + const { results } = await this.db + .prepare( + `SELECT column_name, value + FROM translations + WHERE table_name = ? AND row_id = ? AND locale = ? + AND column_name IN (${placeholders})`, + ) + .bind(tableName, rowId, locale, ...columns) + .all<{ column_name: string; value: string }>(); + + return Object.fromEntries(results.map((r) => [r.column_name, r.value])); + } + + /** + * Upsert translation values for a single row and locale. + * @param values A map of DB column name to translated value + */ + public async set( + tableName: string, + rowId: number, + locale: string, + values: Record, + ): Promise { + const stmt = this.db.prepare( + `INSERT INTO translations (table_name, row_id, column_name, locale, value) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (table_name, row_id, column_name, locale) + DO UPDATE SET value = excluded.value`, + ); + + await this.db.batch( + Object.entries(values).map(([columnName, value]) => + stmt.bind(tableName, rowId, columnName, locale, value), + ), + ); + } + + /** + * Remove every stored translation of one row. Used when overwriting a block + * so locales dropped by the editor do not keep stale translations. + * @param tableName The translated table + * @param rowId The row whose translations are replaced + */ + public async deleteByRow(tableName: string, rowId: number): Promise { + await this.db + .prepare(`DELETE FROM translations WHERE table_name = ? AND row_id = ?`) + .bind(tableName, rowId) + .run(); + } +} + +export const translationRepository = new TranslationRepository(env.DB); diff --git a/src/services/projectReview.ts b/src/services/projectReview.ts new file mode 100644 index 00000000..f5a14f60 --- /dev/null +++ b/src/services/projectReview.ts @@ -0,0 +1,295 @@ +import seed from "../mocks/projectReviews.json"; + +import type { + ProjectReview, + ProjectReviewRisk, + ProjectReviewStatus, + ReviewArea, + ReviewAuthor, + ReviewComment, + ReviewSystemEvent, +} from "../types/projectReview"; + +/** + * Minimal shape of an API failure, matching the `error` the generated client + * resolves with so that callers handle both the same way. + */ +export interface ReviewError { + status: number; +} + +export interface ReviewResult { + data?: T; + error?: ReviewError; +} + +const NOT_FOUND: ReviewError = { status: 404 }; + +/** Identifier of the review the placeholders describe, for links built outside the feature. */ +export const PLACEHOLDER_REVIEW_ID = 1; + +/** + * The reviewed project, as far as it is known before the API carries it. + * + * Only the placeholders fill this in; a real review resolves its project through + * `getReviewedProject` instead. + */ +export interface ReviewProject { + title: string; + status: ProjectReviewStatus; + /** IRI of the User promoting the project. */ + owner: string; +} + +/** + * TEMPORARY stand-in for the reviews API. + * + * The v4 API is still building the review feature, so this module serves the + * placeholders in `src/mocks/projectReviews.json` behind the same `{ data, error }` + * envelope the generated client returns. Every function here maps one-to-one onto + * the endpoint it will eventually become, so landing the API means replacing the + * bodies below with `apiReviews*` calls and deleting the mocks — nothing that + * consumes this module has to change. + * + * Writes mutate an in-memory copy seeded from the JSON, so they last for the + * lifetime of the isolate and are lost on a cold start. That is enough to exercise + * the screens while the API is missing, and it keeps the storage concern in a + * single file. + * + * The placeholders name `/v4/projects/1`, so point `project` at a project that + * exists in the environment being used, and the author IRIs at the users that will + * sign in, otherwise the review screens answer 404. + */ + +let reviews: Map | undefined; + +const seedReviews = seed.reviews as unknown as ProjectReview[]; + +const seedProjects = seed.projects as unknown as Record; + +/** + * The project behind a review, as far as the placeholders know it. + * + * Used to render the review screens for a project the API has no record of yet. + * @param review The review being read + * @returns The placeholder project, or undefined when the review has none + */ +export function getReviewProjectPlaceholder(review: ProjectReview): ReviewProject | undefined { + return seedProjects[review.project]; +} + +/** Author profiles keyed by their User IRI, used to resolve chat bubbles. */ +const authors = new Map( + Object.entries(seed.authors).map(([author, profile]) => [ + author, + { author, ...profile } as ReviewAuthor, + ]), +); + +/** + * The promoter of the reviewed project, as far as the placeholders know them. + * + * @param review The review being read + * @returns The placeholder profile, or undefined when the placeholders know no author for the project's owner + */ +export function getReviewPromoterPlaceholder(review: ProjectReview): ReviewAuthor | undefined { + const project = getReviewProjectPlaceholder(review); + + return project ? authors.get(project.owner) : undefined; +} + +/** + * The consultant admin assigned to the review, as far as the placeholders know them. + * + * @param review The review being read + * @returns The placeholder profile, or undefined when the placeholders know no author for the reviewer + */ +export function getReviewReviewerPlaceholder(review: ProjectReview): ReviewAuthor | undefined { + return authors.get(review.reviewer); +} + +/** + * Seeds the in-memory store on first use. + * + * The parsed JSON is cloned so writes never reach the imported module, which the + * runtime shares across requests handled by the same isolate. + */ +function store(): Map { + if (reviews === undefined) { + const seeded = new Map( + seedReviews.map((review) => [String(review.id), structuredClone(review)]), + ); + + reviews = seeded; + + return seeded; + } + + return reviews; +} + +function find(reviewId: number | string): ProjectReview | undefined { + return store().get(String(reviewId)); +} + +function findArea(review: ProjectReview, areaId: number | string): ReviewArea | undefined { + return review.areas.find((area) => String(area.id) === String(areaId)); +} + +/** + * Reads a review with its areas and their conversations. + * @param id The review identifier, as in `/reviews/{id}` + * @returns The review, or a 404 error when no review carries that id + */ +export function getReview(id: number | string): ReviewResult { + const review = find(id); + + return review ? { data: review } : { error: NOT_FOUND }; +} + +/** + * Reads a single reviewable area, that is, one risk and its conversation. + * @param reviewId The review identifier, as in `/reviews/{id}` + * @param areaId The area identifier, as in `/reviews/{id}/areas/{areaId}` + * @returns The area, or a 404 error when the review or the area does not exist + */ +export function getReviewArea( + reviewId: number | string, + areaId: number | string, +): ReviewResult { + const review = find(reviewId); + const area = review ? findArea(review, areaId) : undefined; + + return area ? { data: area } : { error: NOT_FOUND }; +} + +/** + * Resolves the profiles of everyone who has written in a review: the consultant, + * the promoter, and whoever else takes part in the conversation. + * @param reviewId The review identifier, as in `/reviews/{id}` + * @returns Every known author of the review, empty when the review does not exist + */ +export function getReviewAuthors(reviewId: number | string): ReviewResult { + const review = find(reviewId); + + if (!review) return { error: NOT_FOUND }; + + const iris = new Set([review.reviewer]); + + for (const area of review.areas) { + for (const comment of area.comments) { + iris.add(comment.author); + } + } + + return { + data: [...iris].flatMap((iri) => { + const profile = authors.get(iri); + + return profile ? [profile] : []; + }), + }; +} + +/** + * Appends an entry to the conversation of an area, keeping the id sequence and + * the area IRI in one place whether a person or the platform writes it. + * @param area The area whose conversation receives the entry + * @param author IRI of the User the entry belongs to + * @param body The message body, left empty on entries the platform published + * @param system Set on entries published by the platform instead of a person + * @returns The stored entry + */ +function appendComment( + area: ReviewArea, + author: string, + body: string, + system?: ReviewSystemEvent, +): ReviewComment { + const now = new Date().toISOString(); + + const comment: ReviewComment = { + id: Math.max(0, ...area.comments.map((stored) => stored.id)) + 1, + author, + body, + area: area.review.replace(/\/$/, "") + `/areas/${area.id}`, + dateCreated: now, + dateUpdated: now, + }; + + if (system) comment.system = system; + + area.comments.push(comment); + + return comment; +} + +/** + * Outcome of assessing a risk: the area as it stands, plus the entry the change + * published in its own conversation. + */ +export interface RiskChange { + area: ReviewArea; + /** Absent when there was nothing to announce, such as on a first assessment. */ + entry?: ReviewComment; +} + +/** + * Stores the risk assessed on an area, and announces the change in the area's + * own conversation so both sides find it where they already talk about it. + * + * The announcement only happens on a genuine change between two assessed risks: + * a first assessment has no previous value to name, and picking the same risk + * again is not news. + * + * @param reviewId The review identifier, as in `/reviews/{id}/areas/{areaId}` + * @param areaId The area identifier + * @param risk The assessed risk, or null to clear the assessment + * @param author IRI of the User making the change, kept as the entry's author + * @returns The updated area plus the entry the change published, or a 404 error + * when the review or the area does not exist + */ +export function setReviewAreaRisk( + reviewId: number | string, + areaId: number | string, + risk: ProjectReviewRisk | null, + author: string, +): ReviewResult { + const review = find(reviewId); + const area = review ? findArea(review, areaId) : undefined; + + if (!area) return { error: NOT_FOUND }; + + const previous = area.risk; + + area.risk = risk; + + const entry = + previous && risk && previous !== risk + ? appendComment(area, author, "", { from: previous, to: risk }) + : undefined; + + return { data: { area, entry } }; +} + +/** + * Appends a message to the conversation of an area. + * @param reviewId The review identifier, as in `/reviews/{id}/areas/{areaId}/comments` + * @param areaId The area identifier + * @param author IRI of the User writing the message + * @param body The message body + * @returns The stored message, or a 404 error when the review or the area does not exist + */ +export function createReviewAreaComment( + reviewId: number | string, + areaId: number | string, + author: string, + body: string, +): ReviewResult { + const review = find(reviewId); + const area = review ? findArea(review, areaId) : undefined; + + if (!area) return { error: NOT_FOUND }; + + return { data: appendComment(area, author, body) }; +} diff --git a/src/services/projectsService.ts b/src/services/projectsService.ts deleted file mode 100644 index d5eaf0bb..00000000 --- a/src/services/projectsService.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { apiProjectsGetCollection } from "../openapi/client/sdk.gen"; -import { constrainToPublicStatuses } from "../utils/projectStatus"; - -import type { AuthError } from "../openapi/api"; -import type { Project } from "../openapi/client/types.gen"; -import type { SearchFilters } from "../stores/searchStore"; -import { get } from "svelte/store"; -import { locale } from "../i18n/store"; - -/** - * Simple service wrapper for project API calls - * Based on OpenAPI client documentation - */ -export class ProjectsService { - /** - * Search projects with filters and pagination - * Returns raw Project[] from API - */ - async searchProjects( - filters: SearchFilters, - options?: { - page?: number; - limit?: number; - abortSignal?: AbortSignal; - }, - ): Promise<{ - projects: Project[]; - totalCount: number; - hasNextPage: boolean; - }> { - try { - const { status, "status[]": statuses, ...restFilters } = filters ?? {}; - const response = await apiProjectsGetCollection({ - query: { - ...restFilters, - "status[]": constrainToPublicStatuses([ - ...(statuses ?? []), - ...(status ? [status] : []), - ]), - page: options?.page || 1, - itemsPerPage: options?.limit || 20, - }, - headers: { "Accept-Language": get(locale) }, - ...(options?.abortSignal && { signal: options.abortSignal }), - }); - - // According to OpenAPI types, response.data is Array - const projects = (response.data as Project[]) || []; - const limit = options?.limit || 20; - - // Infer hasNextPage: if we got exactly the requested amount, there might be more - const hasNextPage = projects.length === limit; - - // Without Hydra metadata, we can only use the current page count - // Multiply by current page to give a rough estimate - const totalCount = hasNextPage - ? projects.length * (options?.page || 1) + 1 // At least one more page - : projects.length * (options?.page || 1); - - return { - projects, - totalCount, - hasNextPage, - }; - } catch (error) { - // Re-throw aborted requests as-is so callers can detect cancellation. - // The fetch client may not preserve the DOMException name, so also - // check the signal directly. - if ( - options?.abortSignal?.aborted || - (error instanceof Error && error.name === "AbortError") - ) { - throw new DOMException("Aborted", "AbortError"); - } - - // Re-throw auth errors as-is for component handling - if (error && typeof error === "object" && "type" in error) { - throw error as AuthError; - } - - // Wrap other errors with context - if (error instanceof Error) { - throw new Error(`Project search failed: ${error.message}`, { cause: error }); - } - - throw new Error("Project search failed", { cause: error }); - } - } - - /** - * Get initial projects for SSR - * Returns empty array on error - */ - async getInitialProjects( - locale: string, - options?: { - limit?: number; - abortSignal?: AbortSignal; - }, - ): Promise { - try { - const response = await apiProjectsGetCollection({ - query: { - page: 1, - itemsPerPage: options?.limit || 12, - }, - headers: { - "Accept-Language": locale, - }, - ...(options?.abortSignal && { signal: options.abortSignal }), - }); - - return response.data || []; - } catch { - return []; - } - } - - /** - * Helper: Check if there are more pages - * API doesn't return pagination metadata, so we infer from results - */ - hasNextPage(projects: Project[], limit: number): boolean { - return projects.length === limit; - } -} - -// Export singleton instance -export const projectsService = new ProjectsService(); diff --git a/src/services/reviewAccess.ts b/src/services/reviewAccess.ts new file mode 100644 index 00000000..82d160f6 --- /dev/null +++ b/src/services/reviewAccess.ts @@ -0,0 +1,71 @@ +import { getReviewProjectPlaceholder } from "./projectReview"; +import { apiProjectsIdOrSlugGet, apiUsersGetCollectionUrl } from "../openapi/client"; +import { extractId } from "../utils/extractId"; + +import type { Session } from "../auth/types"; +import type { ProjectReview } from "../types/projectReview"; + +/** + * Whether the signed in user acts as the consultant admin of a review. + * + * Only this role may assess risks and move the reviewed project through its + * review statuses. + * @param session The current session + * @returns True when the user holds the admin role + */ +export function isConsultant(session?: Session): boolean { + return !!session?.user.roles?.includes("ROLE_ADMIN"); +} + +/** + * Whether the signed in user may read a review. + * + * A review is visible to the consultant admin carrying it out and to the promoter + * who owns the reviewed project. + * @param session The current session + * @param review The review being accessed + * @returns True when the user is the consultant or the promoter of the project + */ +export async function canAccessReview( + session: Session | undefined, + review: ProjectReview, +): Promise { + if (!session) return false; + if (isConsultant(session)) return true; + + const projectId = extractId(review.project); + if (!projectId) return false; + + // A promoter only reaches the reviews of their own projects, and their own User + // resource already lists them, so this costs no extra request. + if (Array.isArray(session.user.projects)) { + return session.user.projects.some((iri) => extractId(iri) === projectId); + } + + // Fall back to the Project's owner for sessions whose User carries no project + // list, which is what happens when the API stops hydrating it. + const { data: project } = await apiProjectsIdOrSlugGet({ + path: { idOrSlug: projectId }, + headers: session.token.asHttpHeaders, + }); + + if (project?.owner) { + return extractId(project.owner) === String(session.user.id); + } + + // Nothing to read an owner from: fall back to the placeholder of the mocks, which + // is what a review of a project the API has no record of yet resolves to. + const placeholder = getReviewProjectPlaceholder(review); + + return !!placeholder && extractId(placeholder.owner) === String(session.user.id); +} + +/** + * Builds the IRI of the User the current session belongs to, the shape the API + * uses to reference a comment author. + * @param session The current session + * @returns The User IRI, or null when there is no session + */ +export function sessionUserIri(session: Session): string { + return apiUsersGetCollectionUrl + `/${session.user.id}`; +} diff --git a/src/services/reviewProject.ts b/src/services/reviewProject.ts new file mode 100644 index 00000000..f58e1f3f --- /dev/null +++ b/src/services/reviewProject.ts @@ -0,0 +1,128 @@ +import { + getReviewProjectPlaceholder, + getReviewPromoterPlaceholder, + getReviewReviewerPlaceholder, +} from "./projectReview"; +import { apiProjectsIdOrSlugGet, apiUsersIdOrHandleGet } from "../openapi/client"; +import { extractId } from "../utils/extractId"; + +import type { Session } from "../auth/types"; +import type { Locale } from "../i18n/locales"; +import type { Project } from "../openapi/client"; +import type { ProjectReview, ReviewAuthor } from "../types/projectReview"; + +/** + * One of the two people a review is about, as the review headers name them. + */ +export interface ReviewPerson { + /** `displayName` from the API, falling back to the handle when unset. */ + name: string; + /** Username, shown as `@handle` in titles. */ + handle: string; + avatar?: string; +} + +/** + * The project behind a review, plus who is on either side of it. + */ +export interface ReviewedProject { + title: string; + /** The User promoting the project. */ + promoter?: ReviewPerson; + /** The consultant admin assigned to the review. */ + reviewer?: ReviewPerson; + status?: Project["status"]; +} + +/** + * Resolves the project a review is about, together with the two people naming it. + * + * The API is the source of truth. The placeholder from the mocks only fills the gap + * for a review whose project has no record in the API yet, so the screens stay + * reachable while that data is still being built; both go away with the mocks. + * @param review The review being read + * @param session The current session, needed to read a project only its owner or an admin may see + * @param lang Active locale, sent so the API answers with translated content + * @returns The project and its people, or undefined when neither the API nor the placeholders know it + */ +export async function getReviewedProject( + review: ProjectReview, + session: Session, + lang: Locale, +): Promise { + const projectId = extractId(review.project); + const placeholder = getReviewProjectPlaceholder(review); + const promoterFallback = getReviewPromoterPlaceholder(review); + const reviewerFallback = getReviewReviewerPlaceholder(review); + + const reviewer = await getPerson(review.reviewer, reviewerFallback, session, lang); + + if (!projectId) { + return { + title: placeholder?.title ?? "", + promoter: toPerson(promoterFallback), + reviewer, + status: placeholder?.status, + }; + } + + const { data, error } = await apiProjectsIdOrSlugGet({ + path: { idOrSlug: projectId }, + headers: { ...session.token.asHttpHeaders, "Accept-Language": lang }, + }); + + // A missing project is not fatal for a review: the review is the resource on + // screen, and the placeholders may still know which project it belongs to. + if (!data && error && error.status !== 404) { + console.error({ review: review.id, error }); + } + + const promoter = data?.owner + ? await getPerson(data.owner, promoterFallback, session, lang) + : toPerson(promoterFallback); + + if (data) { + return { title: data.title ?? "", promoter, reviewer, status: data.status }; + } + + return { + title: placeholder?.title ?? "", + promoter, + reviewer, + status: placeholder?.status, + }; +} + +/** + * Resolves a User IRI into the profile the headers need. + * + * Falls back to the placeholders so a review backed by mocks still names both + * people instead of rendering a title with a hole in it. + */ +async function getPerson( + iri: string, + fallback: ReviewAuthor | undefined, + session: Session, + lang: Locale, +): Promise { + const id = extractId(iri); + + if (!id) return toPerson(fallback); + + const { data, error } = await apiUsersIdOrHandleGet({ + path: { idOrHandle: id }, + headers: { ...session.token.asHttpHeaders, "Accept-Language": lang }, + }); + + if (error && error.status !== 404) { + console.error({ user: id, error }); + } + + if (!data) return toPerson(fallback); + + return { name: data.displayName ?? data.handle, handle: data.handle, avatar: data.avatar }; +} + +function toPerson(author?: ReviewAuthor): ReviewPerson | undefined { + return author ? { name: author.displayName, handle: author.handle } : undefined; +} diff --git a/src/stores/checkoutsStore.ts b/src/stores/checkoutsStore.ts index 52c71331..a1244aa0 100644 --- a/src/stores/checkoutsStore.ts +++ b/src/stores/checkoutsStore.ts @@ -43,10 +43,20 @@ export interface StoredCheckout { key: string; items: Record; updatedAt: number; + /** + * Marks a checkout that has been fully claimed/deprecated. The record is + * kept (soft delete) so the data is never lost, but it is no longer + * offered to the shopper or re-submitted by `RewardClaims`. + */ + finalised?: boolean; + finalisedAt?: number; } type CheckoutState = { items: Record; + /** Set once the whole cart has been claimed; see `StoredCheckout`. */ + finalised?: boolean; + finalisedAt?: number; }; export interface CheckoutStore { @@ -126,8 +136,8 @@ function schedulePersist() { const key = activeKey; enqueue(async () => { - const { items } = get(state); - await checkoutRepo.update(key, { items }); + const { items, finalised, finalisedAt } = get(state); + await checkoutRepo.update(key, { items, finalised, finalisedAt }); }); } @@ -147,9 +157,17 @@ if (isBrowser) { let merged: Record = {}; state.update((current) => { merged = { ...loaded, ...current.items }; - return { items: merged }; + return { + items: merged, + finalised: current.finalised ?? record?.finalised, + finalisedAt: current.finalisedAt ?? record?.finalisedAt, + }; + }); + await checkoutRepo.update(key, { + items: merged, + finalised: record?.finalised, + finalisedAt: record?.finalisedAt, }); - await checkoutRepo.update(key, { items: merged }); } hydrated.set(true); @@ -271,16 +289,29 @@ export const cart: CheckoutStore = { clearForUser: (userId?: string | number) => { skipHydrationMerge = true; - state.set({ items: {} }); + + // Soft delete: mark the cart as finalised instead of destroying it, so + // the record (and the reason it was claimed) is never lost. RewardClaims + // exits early on the `finalised` flag, so nothing is re-submitted. + const now = Date.now(); + state.set({ items: {}, finalised: true, finalisedAt: now }); if (!isBrowser) return; enqueue(async () => { - await checkoutRepo.remove(GUEST_CHECKOUT_KEY); + await checkoutRepo.update(GUEST_CHECKOUT_KEY, { + items: {}, + finalised: true, + finalisedAt: now, + }); const key = checkoutKeyForUser(userId); if (key !== GUEST_CHECKOUT_KEY) { - await checkoutRepo.remove(key); + await checkoutRepo.update(key, { + items: {}, + finalised: true, + finalisedAt: now, + }); } }); dropLegacyCart(); diff --git a/src/stores/projectCache.ts b/src/stores/projectCache.ts deleted file mode 100644 index 4ff5c5ce..00000000 --- a/src/stores/projectCache.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { writable } from "svelte/store"; - -import type { Project } from "../openapi/client/types.gen"; - -/** - * Project Cache Store - * - * Session-level cache for project details to avoid refetching the same projects - * across component mounts and page navigations. - * - * Key features: - * - Multi-key indexing: Projects are indexed by ID, slug, and original idOrSlug - * - Persistent across component mounts - * - Shared across all components - * - Automatic cache validation - * - * Usage: - * ```ts - * import { projectCache } from './stores/projectCache'; - * - * // Add a project to the cache - * projectCache.add(project, originalIdOrSlug); - * - * // Get a project from the cache - * const project = projectCache.get(idOrSlug); - * - * // Clear the cache - * projectCache.clear(); - * ``` - */ - -interface ProjectCacheStore { - projects: Map; -} - -function createProjectCache() { - const { subscribe, update } = writable({ - projects: new Map(), - }); - - return { - subscribe, - - /** - * Add a project to the cache using multiple keys for reliable lookups - * @param project - The project to cache - * @param originalKey - Optional original ID or slug used to fetch this project - */ - add: (project: Project, originalKey?: string) => { - update((state) => { - const newProjects = new Map(state.projects); - - // Always add using the original key if provided - if (originalKey) { - newProjects.set(originalKey, project); - } - - // Add by slug (preferred for URLs) - if (project.slug) { - newProjects.set(project.slug, project); - } - - // Add by ID (for API IRI lookups) - if (project.id) { - newProjects.set(project.id.toString(), project); - } - - return { projects: newProjects }; - }); - }, - - /** - * Add multiple projects to the cache - * @param projects - Array of projects to cache - */ - addMany: (projects: Project[]) => { - update((state) => { - const newProjects = new Map(state.projects); - - projects.forEach((project) => { - // Add by slug - if (project.slug) { - newProjects.set(project.slug, project); - } - - // Add by ID - if (project.id) { - newProjects.set(project.id.toString(), project); - } - }); - - return { projects: newProjects }; - }); - }, - - /** - * Get a project from the cache by ID or slug - * @param key - Project ID or slug - * @returns The cached project or undefined - */ - get: (key: string | undefined): Project | undefined => { - if (!key) return undefined; - - let result: Project | undefined; - update((state) => { - result = state.projects.get(key); - return state; - }); - return result; - }, - - /** - * Check if a project exists in the cache - * @param key - Project ID or slug - * @returns True if the project is cached - */ - has: (key: string | undefined): boolean => { - if (!key) return false; - - let result = false; - update((state) => { - result = state.projects.has(key); - return state; - }); - return result; - }, - - /** - * Clear all cached projects - */ - clear: () => { - update(() => ({ projects: new Map() })); - }, - - /** - * Get cache statistics for debugging - * @returns Object containing cache size and sample keys - */ - getStats: () => { - const stats = { size: 0, sampleKeys: [] as string[] }; - update((state) => { - stats.size = state.projects.size; - stats.sampleKeys = Array.from(state.projects.keys()).slice(0, 10); - return state; - }); - return stats; - }, - }; -} - -export const projectCache = createProjectCache(); diff --git a/src/stores/searchStore.ts b/src/stores/searchStore.ts deleted file mode 100644 index 92a04afe..00000000 --- a/src/stores/searchStore.ts +++ /dev/null @@ -1,509 +0,0 @@ -import { writable, derived, get } from "svelte/store"; - -import { projectsService } from "../services/projectsService"; - -import type { ApiProjectsGetCollectionData, Project } from "../openapi/client/types.gen"; - -export type SearchFilters = ApiProjectsGetCollectionData["query"]; - -export interface SearchState { - filters: SearchFilters; - results: Project[]; - isLoading: boolean; - hasError: boolean; - errorMessage: string; - totalCount: number; - hasSearched: boolean; - lastSearchTime: number; - // Pagination state - currentPage: number; - itemsPerPage: number; - hasNextPage: boolean; - hasPrevPage: boolean; - isLoadingMore: boolean; - currentAbortController?: AbortController; -} - -// Browser check for SSR safety -const isBrowser = typeof window !== "undefined"; - -// Initialize search state -function getInitialState(): SearchState { - return { - filters: { - page: 1, - itemsPerPage: 18, - }, - results: [], - isLoading: false, - hasError: false, - errorMessage: "", - totalCount: 0, - hasSearched: false, - lastSearchTime: 0, - // Pagination state - currentPage: 1, - itemsPerPage: 18, - hasNextPage: false, - hasPrevPage: false, - isLoadingMore: false, - currentAbortController: undefined, - }; -} - -export function parseSearchParamsFilters(searchParams: URLSearchParams) { - const filters: Record = {}; - - for (const [key] of searchParams.entries()) { - const values = searchParams.getAll(key); - - if (values.length > 1 || key.endsWith("[]")) { - filters[key] = values; - } else { - filters[key] = values[0]; - } - } - - return filters; -} - -// Load initial state from URL parameters if available -function loadInitialState(): SearchState { - if (!isBrowser) return getInitialState(); - - const state = getInitialState(); - const urlParams = new URLSearchParams(window.location.search); - - // Initialize filters from URL parameters using API field names - if (state.filters) { - state.filters = parseSearchParamsFilters(urlParams); - - if ([...urlParams.keys()].length > 0) { - state.hasSearched = true; - } - } - return state; -} - -function createSearchStore() { - const { subscribe, set, update } = writable(loadInitialState()); - - // Direct search function - executes immediately when called - const performSearch = async (filters: SearchFilters) => { - try { - const currentState = get(store); - - // Cancel previous request if exists - if (currentState.currentAbortController) { - currentState.currentAbortController.abort(); - } - - // Create new abort controller - const abortController = new AbortController(); - - update((state) => ({ - ...state, - isLoading: true, - isLoadingMore: false, - hasError: false, - errorMessage: "", - currentAbortController: abortController, - })); - - const response = await projectsService.searchProjects(filters, { - page: currentState.currentPage, - limit: currentState.itemsPerPage, - abortSignal: abortController.signal, - }); - - // Check if request wasn't cancelled - if (!abortController.signal.aborted) { - const hasPrevPage = currentState.currentPage > 1; - - update((state) => ({ - ...state, - results: response.projects, - totalCount: response.totalCount, - hasNextPage: response.hasNextPage, - hasPrevPage, - isLoading: false, - hasSearched: true, - lastSearchTime: Date.now(), - currentAbortController: undefined, - })); - } - } catch (error) { - // Ignore cancelled requests — a new search aborted this one - if (error instanceof Error && error.name === "AbortError") { - return; - } - console.error(error); - update((state) => ({ - ...state, - isLoading: false, - hasError: true, - errorMessage: error instanceof Error ? error.message : "Search failed", - currentAbortController: undefined, - })); - } - }; - - const store = { - subscribe, - - // Update filters without triggering search - updateFilters: (newFilters: Partial) => { - update((state) => ({ - ...state, - filters: { ...state.filters, ...newFilters }, - })); - }, - - // Initialize filters (for SSR hydration) - initializeFilters: (newFilters: Partial) => { - update((state) => ({ - ...state, - filters: { ...state.filters, ...newFilters }, - })); - }, - - // Set search results - setResults: (results: Project[], totalCount: number = results.length) => - update((state) => ({ - ...state, - results, - totalCount, - isLoading: false, - hasError: false, - errorMessage: "", - hasSearched: true, - lastSearchTime: Date.now(), - })), - - // Set initial results without marking as searched (for SSR data) - setInitialResults: (results: Project[], totalCount: number = results.length) => { - return update((state) => ({ - ...state, - results, - totalCount, - isLoading: false, - hasError: false, - errorMessage: "", - hasSearched: false, - lastSearchTime: Date.now(), - })); - }, - - // Set loading state - setLoading: (isLoading: boolean) => - update((state) => ({ - ...state, - isLoading, - hasError: false, - errorMessage: "", - })), - - // Set error state - setError: (errorMessage: string) => - update((state) => ({ - ...state, - isLoading: false, - hasError: true, - errorMessage, - })), - - // Clear search results - clearResults: () => - update((state) => ({ - ...state, - results: [], - totalCount: 0, - hasSearched: false, - hasError: false, - errorMessage: "", - })), - - // Clear all filters - clearFilters: () => - update((state) => ({ - ...state, - filters: { - page: 1, - itemsPerPage: 18, - }, - })), - - // Reset to initial state - reset: () => set(getInitialState()), - - // Update URL parameters to reflect current filters - updateUrl: () => { - if (!isBrowser) return; - - const state = get(searchStore); - const urlParams = new URLSearchParams(); - const filters = state.filters ?? {}; - - Object.entries(filters).forEach(([key, value]) => { - if (value === undefined || value === null || value === "") return; - - if (Array.isArray(value)) { - value.forEach((item) => { - urlParams.append(key, String(item)); - }); - } else { - if (key !== "page" && key !== "itemsPerPage") { - urlParams.set(key, String(value)); - } - } - }); - - const newUrl = urlParams.toString() - ? `${window.location.pathname}?${urlParams.toString()}` - : window.location.pathname; - - window.history.replaceState({}, "", newUrl); - }, - - // Initialize store with server-provided data - initializeFromServer: ( - filters: SearchFilters, - results: Project[], - totalCount: number, - pagination?: { - currentPage: number; - hasNext: boolean; - hasPrev: boolean; - }, - ) => - update((state) => ({ - ...state, - filters, - results, - totalCount, - currentPage: pagination?.currentPage || 1, - hasNextPage: pagination?.hasNext || false, - hasPrevPage: pagination?.hasPrev || false, - hasSearched: - totalCount > 0 || - !!(filters?.title || (filters?.["categories[]"]?.length ?? 0) > 0), - isLoading: false, - hasError: false, - errorMessage: "", - lastSearchTime: Date.now(), - })), - - // Search with API (manual trigger) - searchWithApi: async (resetPage = true) => { - if (resetPage) { - update((state) => ({ - ...state, - currentPage: 1, - })); - } - - // Get the updated state AFTER resetting the page - const currentState = get(store); - await performSearch(currentState.filters); - }, - - // Load next page - loadNextPage: async () => { - const currentState = get(store); - if (!currentState.hasNextPage || currentState.isLoading) return; - - update((state) => ({ - ...state, - currentPage: state.currentPage + 1, - })); - - await store.searchWithApi(false); - }, - - // Load previous page - loadPreviousPage: async () => { - const currentState = get(store); - if ( - !currentState.hasPrevPage || - currentState.isLoading || - currentState.currentPage <= 1 - ) - return; - - update((state) => ({ - ...state, - currentPage: state.currentPage - 1, - })); - - await store.searchWithApi(false); - }, - - // Go to specific page - goToPage: async (page: number) => { - const currentState = get(store); - if (page < 1 || currentState.isLoading) return; - - update((state) => ({ - ...state, - currentPage: page, - })); - - await store.searchWithApi(false); - }, - - // Cancel current API request - cancelRequest: () => { - update((state) => { - if (state.currentAbortController) { - state.currentAbortController.abort(); - } - return { - ...state, - isLoading: false, - currentAbortController: undefined, - }; - }); - }, - - // Load more results (accumulative pagination) - loadMoreResults: async () => { - const currentState = get(store); - if (!currentState.hasNextPage || currentState.isLoading) return; - - try { - // Increment page before fetching - update((state) => ({ - ...state, - currentPage: state.currentPage + 1, - })); - - const nextPageState = get(store); - - // Cancel previous request if exists - if (nextPageState.currentAbortController) { - nextPageState.currentAbortController.abort(); - } - - // Create new abort controller - const abortController = new AbortController(); - - update((state) => ({ - ...state, - isLoading: true, - isLoadingMore: true, - hasError: false, - errorMessage: "", - currentAbortController: abortController, - })); - - const response = await projectsService.searchProjects(nextPageState.filters, { - page: nextPageState.currentPage, - limit: nextPageState.itemsPerPage, - abortSignal: abortController.signal, - }); - - // Check if request wasn't cancelled - if (!abortController.signal.aborted) { - const hasPrevPage = nextPageState.currentPage > 1; - - update((state) => ({ - ...state, - // Append new results to existing results - results: [...state.results, ...response.projects], - totalCount: response.totalCount, - hasNextPage: response.hasNextPage, - hasPrevPage, - isLoading: false, - isLoadingMore: false, - hasSearched: true, - lastSearchTime: Date.now(), - currentAbortController: undefined, - })); - } - } catch (error) { - // Only set error if request wasn't cancelled - // Keep existing results on error - if (!(error instanceof Error) || error.name !== "AbortError") { - update((state) => ({ - ...state, - isLoading: false, - isLoadingMore: false, - hasError: true, - errorMessage: - error instanceof Error ? error.message : "Failed to load more results", - currentAbortController: undefined, - // Revert page increment on error - currentPage: state.currentPage - 1, - })); - } - } - }, - }; - - return store; -} - -// Create the main search store -export const searchStore = createSearchStore(); - -// Derived stores for computed values -export const searchFilters = derived(searchStore, ($searchStore) => $searchStore.filters || {}); - -export const searchResults = derived(searchStore, ($searchStore) => $searchStore.results); - -export const isSearching = derived(searchStore, ($searchStore) => $searchStore.isLoading); - -// True only while appending more results (Load more) — not during a fresh search -export const isLoadingMore = derived(searchStore, ($searchStore) => $searchStore.isLoadingMore); - -export const searchError = derived(searchStore, ($searchStore) => - $searchStore.hasError ? $searchStore.errorMessage : null, -); - -export const hasSearchResults = derived( - searchStore, - ($searchStore) => $searchStore.results.length > 0, -); - -export const hasActualSearchResults = derived( - searchStore, - ($searchStore) => $searchStore.hasSearched && $searchStore.results.length > 0, -); - -export const hasInitialResults = derived( - searchStore, - ($searchStore) => !$searchStore.hasSearched && $searchStore.results.length > 0, -); - -export const isEmpty = derived( - searchStore, - ($searchStore) => $searchStore.hasSearched && $searchStore.results.length === 0, -); - -export const resultCount = derived(searchStore, ($searchStore) => $searchStore.totalCount); - -// Derived store to check if any filters are active -export const hasActiveFilters = derived(searchStore, ($searchStore) => { - const paginationKeys = ["page", "itemsPerPage"]; - return Object.entries($searchStore.filters ?? {}).some( - ([key, value]) => - !paginationKeys.includes(key) && - (Array.isArray(value) ? value.length > 0 : value != null && value !== ""), - ); -}); - -// Pagination derived stores -export const currentPage = derived(searchStore, ($searchStore) => $searchStore.currentPage); - -export const hasNextPage = derived(searchStore, ($searchStore) => $searchStore.hasNextPage); - -export const hasPrevPage = derived(searchStore, ($searchStore) => $searchStore.hasPrevPage); - -// Pagination info derived store -export const paginationInfo = derived(searchStore, ($searchStore) => ({ - currentPage: $searchStore.currentPage, - totalCount: $searchStore.totalCount, - hasNext: $searchStore.hasNextPage, - hasPrev: $searchStore.hasPrevPage, - isLoading: $searchStore.isLoading, -})); diff --git a/src/styles/global.css b/src/styles/global.css index c7e234ec..e42d22cc 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -15,33 +15,34 @@ @theme { /* Light Theme (default) */ - --color-primary: #59e9d3; - --color-secondary: #462949; - --color-tertiary: #e94668; - --color-content: #575757; + --color-primary: #ff4d9a; + --color-secondary: #2ee6f6; + --color-tertiary: #a66bff; + --color-content: #f2f2f7; /* Extended color tokens for components */ - --color-white: #fbfbfb; - --color-black: #3d3d3d; - --color-grey: #f3f3ef; - --color-purple-soft: #faf9ff; - --color-purple-med: #f6f5ff; + --color-white: #0f0f14; + --color-black: #ffffff; + --color-grey: #23262e; + --color-purple-soft: #171a22; + --color-purple-med: #211c36; /* Scrim tras diálogos y modales: gris cálido al 70% */ --color-overlay: #878282b2; - --color-variant1: #e6e5f7; - --color-variant2: #99ffcc; - --color-variant3: #72d8ff; - --color-variant4: #ffff99; + --color-variant1: #4a1e2b; + --color-variant2: #24d3a1; + --color-variant3: #2ee6f6; + --color-variant4: #ffc857; - --color-semantic-error: #e9b1bd; - --color-semantic-success: #b1e9e1; - --color-semantic-notification: #c2eeff; - --color-semantic-warning: #ffffc2; + --color-semantic-error: #ff6b6b; + --color-semantic-success: #24d3a1; + --color-semantic-notification: #183b46; + --color-semantic-warning: #ffc857; /* Declared by the `` component in Layout.astro; already carries its own fallbacks. */ --font-body: var(--font-karla); + --font-title: var(--font-boldonse); --text-body-small: 0.875rem; --text-double: 2rem; } @@ -89,6 +90,12 @@ body.grid { width: 100%; } +h1, +h2, +h3 { + font-family: var(--font-title); +} + astro-island { display: contents; } diff --git a/src/styles/marked.css b/src/styles/marked.css index 00faf1d0..ed7ecca4 100644 --- a/src/styles/marked.css +++ b/src/styles/marked.css @@ -37,7 +37,15 @@ list-style: initial; } .marked-content ol { - list-style: initial; + padding-left: var(--text-xl); + padding-bottom: var(--text-lg); + list-style: decimal; +} + +.marked-content blockquote { + border-left: 4px solid var(--color-primary); + padding-left: var(--text-lg); + margin-bottom: var(--text-lg); } .marked-content h3 { diff --git a/src/types/campaign.ts b/src/types/campaign.ts index 599522b2..c48023e1 100644 --- a/src/types/campaign.ts +++ b/src/types/campaign.ts @@ -18,7 +18,6 @@ export type CampaignSize = "small" | "large"; * - `obtained` and `minimum`: Funding amounts from project.funding * - `image`: Path from project.image * - `userDonations`: Aggregated from user's contributions - * - `daysRemaining`: Calculated from campaign end date * - `hasMatchfunding`: Indicates if project participates in matchfunding * - `tags`: Custom display tags for the project * - `category`: Project category for filtering and display @@ -42,8 +41,6 @@ export interface Campaign extends Project { userDonations?: Money; /** Project category: solidary, ecology, democracy, culture, etc. */ category?: string; - /** Days remaining until campaign ends */ - daysRemaining?: number; /** Whether this project participates in matchfunding */ hasMatchfunding?: boolean; /** Custom display tags */ diff --git a/src/types/footer.ts b/src/types/footer.ts index 0c8cae81..7580896a 100644 --- a/src/types/footer.ts +++ b/src/types/footer.ts @@ -8,6 +8,10 @@ export interface FooterLinkItem { href: string; } +export interface FooterLinkSocial extends FooterLinkItem { + icon: Component<{ width?: string | number; height?: string | number }>; +} + export interface FooterNavColumn { /** i18n key for the column heading */ titleKey: string; @@ -23,13 +27,6 @@ export interface FooterPartner { class?: ClassNameValue; } -export interface FooterSocialLink { - icon: Component<{ width?: string | number; height?: string | number }>; - href: string; - /** i18n key for the link's aria-label */ - ariaLabelKey: string; -} - export interface FooterConfig { /** first band — non-linked funding logos */ funding: FooterPartner[]; @@ -37,7 +34,7 @@ export interface FooterConfig { partOf: FooterPartner[]; navColumns: FooterNavColumn[]; legalLinks: FooterLinkItem[]; - social: FooterSocialLink[]; + social: FooterLinkSocial[]; brand: { backgroundImage: string; logo: { src: string; alt: string }; diff --git a/src/types/me-page.ts b/src/types/me-page.ts index e50fd6c0..d0810db5 100644 --- a/src/types/me-page.ts +++ b/src/types/me-page.ts @@ -71,10 +71,6 @@ export interface DonationItem { * Project slug for URL construction */ projectSlug: string; - /** - * Date of the donation (ISO 8601 format) - */ - date: string; } /** diff --git a/src/types/projectReview.ts b/src/types/projectReview.ts index a167f3bc..06eef56b 100644 --- a/src/types/projectReview.ts +++ b/src/types/projectReview.ts @@ -1,26 +1,110 @@ /** - * Risk level assigned to a reviewed area of a project. + * Risk level assessed on a review area. * * Values match the `domain.review.risks.*` translation keys. */ export type ProjectReviewRisk = "low" | "medium" | "high"; +/** Stage of the project life-cycle a review belongs to. */ +export type ProjectReviewType = "campaign" | "financial"; + /** - * A single reviewable area of a project during campaign or funding review. + * Project statuses a consultant admin can move a project to from a review. + * + * `to_campaign_review` is deliberately left out: a review can only exist once the + * project has left that state, so offering it would allow a project to be pushed + * back to a step it already completed. + */ +export const PROJECT_REVIEW_STATUSES = [ + "in_campaign_review", + "in_campaign_review.to_change", + "in_campaign_review.to_review", + "campaign_review.rejected", +] as const; + +export type ProjectReviewStatus = (typeof PROJECT_REVIEW_STATUSES)[number]; + +/** + * An entry the platform published in a conversation on its own, rather than a + * message somebody wrote. + * + * It sits in the same list as the messages so it keeps its place in time, and + * carries the detail the card is built from instead of a written body. + */ +export interface ReviewSystemEvent { + /** Risk the area held before the change. */ + from: ProjectReviewRisk; + /** Risk the area holds after the change, which the card announces. */ + to: ProjectReviewRisk; +} + +/** + * A message exchanged between the promoter and the consultant admin about one area. * * NOTE: this is not part of the OpenAPI spec — the v4 API exposes no review - * resources yet, so `src/openapi/client` has no generated equivalent. Replace - * this interface with the generated type once the API ships those endpoints. + * resources yet, so `src/openapi/client` has no generated equivalent. Replace this + * interface with the generated type once the API ships those endpoints. */ -export interface ProjectReviewArea { +export interface ReviewComment { + id: number; + /** IRI of the User who wrote the message. */ + author: string; + /** IRI of the ReviewArea this message belongs to. */ + area: string; + /** + * Message text. Stays empty on system entries, which read their card off + * {@link ReviewComment.system} so the wording follows the active locale. + */ + body: string; + /** ISO 8601 date, provided by the API `DateCreatedTrait`. */ + dateCreated: string; + /** ISO 8601 date, provided by the API `DateUpdatedTrait`. */ + dateUpdated: string; + /** Set only on entries published by the platform; absent on messages. */ + system?: ReviewSystemEvent; +} + +/** + * A reviewable area of a project, that is, one of the risks the consultant + * assesses. Each area carries its own conversation between promoter and admin. + * + * NOTE: this is not part of the OpenAPI spec — see {@link ReviewComment}. + */ +export interface ReviewArea { + id: number; + /** IRI of the ProjectReview this area belongs to. */ + review: string; /** Name of the reviewed area. */ title: string; /** Reviewer's summary of the findings for this area. */ summary: string; - /** Risk assessed for this area, if it has been evaluated. */ - risk?: ProjectReviewRisk; - /** Number of chat messages exchanged about this area. */ - chatCount?: number; - /** ISO 8601 date of the last message in this area's chat. */ - lastActivity?: string; + /** Risk assessed for this area, null while it has not been evaluated. */ + risk: ProjectReviewRisk | null; + comments: ReviewComment[]; +} + +/** + * A review of a project carried out by a consultant admin. + * + * NOTE: this is not part of the OpenAPI spec — see {@link ReviewComment}. + */ +export interface ProjectReview { + id: number; + /** IRI of the reviewed Project. */ + project: string; + /** IRI of the User acting as consultant. */ + reviewer: string; + type: ProjectReviewType; + areas: ReviewArea[]; +} + +/** + * The subset of User data a chat bubble needs, resolved from an author IRI. + */ +export interface ReviewAuthor { + /** IRI of the User resource. */ + author: string; + handle: string; + displayName: string; + avatar?: string; } diff --git a/src/utils/adminTable.ts b/src/utils/adminTable.ts index 9bf180a7..835a39f2 100644 --- a/src/utils/adminTable.ts +++ b/src/utils/adminTable.ts @@ -12,10 +12,10 @@ export function isValidItemsPerPage(value: number): value is AdminItemsPerPage { return (ADMIN_ITEMS_PER_PAGE_OPTIONS as readonly number[]).includes(value); } -export function getInitialItemsPerPage(): AdminItemsPerPage { +export function getInitialItemsPerPage(cookie = ADMIN_ITEMS_PER_PAGE_COOKIE): AdminItemsPerPage { if (typeof document === "undefined") return DEFAULT_ITEMS_PER_PAGE; - const raw = getCookie(ADMIN_ITEMS_PER_PAGE_COOKIE); + const raw = getCookie(cookie); if (!raw) return DEFAULT_ITEMS_PER_PAGE; const parsed = Number(raw); diff --git a/src/utils/adminTableState.svelte.ts b/src/utils/adminTableState.svelte.ts index 21ba9c92..aa325bb4 100644 --- a/src/utils/adminTableState.svelte.ts +++ b/src/utils/adminTableState.svelte.ts @@ -1,24 +1,17 @@ import { ADMIN_ITEMS_PER_PAGE_COOKIE, - DEFAULT_ITEMS_PER_PAGE, + getInitialItemsPerPage, isValidItemsPerPage, type AdminItemsPerPage, } from "./adminTable"; -import { getCookie, setCookie } from "./cookies"; +import { setCookie } from "./cookies"; -function getInitialItemsPerPage(): AdminItemsPerPage { - if (typeof document === "undefined") return DEFAULT_ITEMS_PER_PAGE; - - const raw = getCookie(ADMIN_ITEMS_PER_PAGE_COOKIE); - if (!raw) return DEFAULT_ITEMS_PER_PAGE; - - const parsed = Number(raw); - return isValidItemsPerPage(parsed) ? parsed : DEFAULT_ITEMS_PER_PAGE; -} - -export function useAdminTableState(initialSort: T) { +export function useAdminTableState( + initialSort: T, + cookie = ADMIN_ITEMS_PER_PAGE_COOKIE, +) { let currentPage = $state(1); - let itemsPerPage = $state(getInitialItemsPerPage()); + let itemsPerPage = $state(getInitialItemsPerPage(cookie)); let selectedSort = $state(initialSort); let isLoading = $state(false); let isFirstLoad = $state(true); @@ -67,7 +60,7 @@ export function useAdminTableState(initialSort: T) { handleItemsPerPageChange(perPage: number) { if (isValidItemsPerPage(perPage)) { itemsPerPage = perPage; - setCookie(ADMIN_ITEMS_PER_PAGE_COOKIE, String(perPage)); + setCookie(cookie, String(perPage)); currentPage = 1; } }, diff --git a/src/utils/campaign.ts b/src/utils/campaign.ts index e69de29b..9fd58e49 100644 --- a/src/utils/campaign.ts +++ b/src/utils/campaign.ts @@ -0,0 +1,29 @@ +import type { ProjectCalendar } from "../openapi/client"; + +const DAY_MS = 1000 * 60 * 60 * 24; + +/** End date of the round in progress (minimum, then optimum), or `undefined` once both have passed. */ +export function getCurrentDeadline(calendar: ProjectCalendar): Date | undefined { + const now = new Date(); + + const minimum = new Date(calendar.minimum!); + if (now < minimum) { + return minimum; + } + + if (!calendar.optimum) { + return undefined; + } + + const optimum = new Date(calendar.optimum); + if (now < optimum) { + return optimum; + } + + return undefined; +} + +export function getDaysRemaining(calendar?: ProjectCalendar): number | undefined { + const deadline = calendar?.minimum ? getCurrentDeadline(calendar) : undefined; + return deadline ? Math.ceil((deadline.getTime() - Date.now()) / DAY_MS) : undefined; +} diff --git a/src/utils/countries.ts b/src/utils/countries.ts new file mode 100644 index 00000000..c0c5d9c7 --- /dev/null +++ b/src/utils/countries.ts @@ -0,0 +1,15 @@ +import iso3166 from "iso-3166-2"; + +/** + * Every country as `{ code, name }`, sorted by name in the given language, ready to feed a `Select`. + * + * The API stores and validates the country as an ISO 3166-1 alpha-2 string but exposes no list of + * them, so the options are built from the ISO data here and the names localized with `Intl`. + */ +export function getCountries(lang: Intl.LocalesArgument): { code: string; name: string }[] { + const names = new Intl.DisplayNames(lang, { type: "region" }); + + return Object.keys(iso3166.data) + .map((code) => ({ code, name: names.of(code) ?? code })) + .sort((a, b) => a.name.localeCompare(b.name, lang)); +} diff --git a/src/utils/dates.ts b/src/utils/dates.ts index b290015e..b67a1f38 100644 --- a/src/utils/dates.ts +++ b/src/utils/dates.ts @@ -10,6 +10,21 @@ export function formatDate(date: Date, locale: Locale) { return date.toLocaleDateString(locale, options); } +/** + * The clock time a moment fell on, without the date. + * @param date The moment to write out + * @param locale Active locale, which sets the time separator + * @returns The hours and minutes, as "HH:mm" + */ +export function formatTime(date: Date, locale: Locale) { + const options: Intl.DateTimeFormatOptions = { + hour: "2-digit", + minute: "2-digit", + }; + + return date.toLocaleTimeString(locale, options); +} + /** * The first instant of the day for the given date (midnight, 00:00). * @param date The date to normalize diff --git a/src/utils/debounce.ts b/src/utils/debounce.ts new file mode 100644 index 00000000..ef484ba6 --- /dev/null +++ b/src/utils/debounce.ts @@ -0,0 +1,16 @@ +/** + * Delays `fn` until `ms` have passed without another call. Each call cancels + * the pending one; `cancel()` drops it without running. + */ +export function debounce(fn: (...args: A) => void, ms = 300) { + let timer: ReturnType | undefined; + + const debounced = (...args: A) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), ms); + }; + + debounced.cancel = () => clearTimeout(timer); + + return debounced; +} diff --git a/src/utils/hydra.ts b/src/utils/hydra.ts index 65288cf9..fef19e66 100644 --- a/src/utils/hydra.ts +++ b/src/utils/hydra.ts @@ -19,3 +19,16 @@ export function toCollectionItems(collection: unknown): T[] { return []; } + +export function getCollectionTotalItems(collection: unknown): number { + if (collection && typeof collection === "object" && !Array.isArray(collection)) { + const record = collection as Record; + const totalItems = record.totalItems ?? record["hydra:totalItems"]; + + if (typeof totalItems === "number") { + return totalItems; + } + } + + return toCollectionItems(collection).length; +} diff --git a/src/utils/lang.ts b/src/utils/lang.ts index 5d75f708..aad2de2b 100644 --- a/src/utils/lang.ts +++ b/src/utils/lang.ts @@ -1,8 +1,13 @@ export function getLanguageDisplayName(lang: string): string | undefined { - const displayNames = new Intl.DisplayNames(lang, { type: "language" }); - const displayName = displayNames.of(lang)!; + let displayName: string | undefined; - if (displayName === lang) return; + try { + displayName = new Intl.DisplayNames(lang, { type: "language" }).of(lang); + } catch { + return; + } + + if (!displayName || displayName === lang) return; return displayName.charAt(0).toUpperCase() + displayName.slice(1); } diff --git a/src/utils/projectStatus.ts b/src/utils/projectStatus.ts index e8f9cf28..22752937 100644 --- a/src/utils/projectStatus.ts +++ b/src/utils/projectStatus.ts @@ -1,3 +1,5 @@ +import type { Project } from "../openapi/client/index.ts"; + /** * Public project statuses for the public search page. * @@ -61,3 +63,13 @@ export function constrainToPublicStatuses(statuses: string[] = []): string[] { const valid = statuses.filter((status) => PUBLIC_API_STATUSES.has(status)); return valid.length ? valid : expandPublicStatuses(ALL_PUBLIC_STATUSES); } + +/** Campaign review statuses: listed in the admin review page and selectable in its dropdown. */ +export const CAMPAIGN_REVIEW_STATUSES: NonNullable[] = [ + "to_campaign_review", + "in_campaign_review", + "in_campaign_review.to_review", + "in_campaign_review.to_change", + "campaign_review.rejected", + "to_campaign", +]; diff --git a/src/utils/review.ts b/src/utils/review.ts new file mode 100644 index 00000000..49b076df --- /dev/null +++ b/src/utils/review.ts @@ -0,0 +1,76 @@ +import { startOfDay } from "./dates"; +import { extractId } from "./extractId"; + +import type { ProjectReviewRisk, ProjectReviewStatus, ReviewComment } from "../types/projectReview"; + +/** + * Whether two references point at the same User. + * + * IRIs arrive both absolute and relative depending on who wrote them — the API builds + * them from its collection URL, the placeholders spell them out — so they are compared + * by id instead of as strings. + * @param first One User IRI + * @param second Another User IRI + * @returns True when both references resolve to the same id + */ +export function isSameAuthor(first: string, second: string): boolean { + return first === second || extractId(first) === extractId(second); +} + +/** Every risk a reviewable area can be assessed with, from least to most severe. */ +export const REVIEW_RISKS: ProjectReviewRisk[] = ["low", "medium", "high"]; + +/** Tag variant each risk level is painted with, matching `domain.review.risks.*`. */ +export const RISK_TAG_VARIANTS: Record = { + low: "success", + medium: "warning", + high: "error", +}; + +/** + * Translation key of each review status. + * + * The API writes the status of a review with a dot separator (`campaign_review.rejected`) + * while the translations folder names it with an underscore, hence the explicit map. + */ +export const PROJECT_REVIEW_STATUS_LABELS: Record = { + in_campaign_review: "domain.project.status.in_campaign_review", + "in_campaign_review.to_change": "domain.project.status.in_campaign_review_to_change", + "in_campaign_review.to_review": "domain.project.status.in_campaign_review_to_review", + "campaign_review.rejected": "domain.project.status.campaign_review_rejected", +}; + +/** + * The moment a conversation was last touched, used for the activity line of a card. + * @param comments The messages of an area + * @returns The date of the newest message, or null when the conversation is empty + */ +export function lastActivity(comments: ReviewComment[]): Date | null { + if (!comments.length) return null; + + return new Date( + comments.reduce( + (latest, comment) => Math.max(latest, new Date(comment.dateCreated).getTime()), + 0, + ), + ); +} + +/** The calendar day a moment belongs to, as the value two dates are compared by. */ +export function dayOf(date: Date): number { + return startOfDay(date).getTime(); +} + +/** + * Whether a message is the first one of a new day in the conversation. + * + * Chats break the thread with a date between days, so this marks where to place it. + * @param previous The message right before this one, if any + * @param current The message being rendered + * @returns True when there is no previous message or both fall on different days + */ +export function opensNewDay(previous: ReviewComment | undefined, current: ReviewComment): boolean { + if (!previous) return true; + + return dayOf(new Date(previous.dateCreated)) !== dayOf(new Date(current.dateCreated)); +} diff --git a/src/utils/richText.ts b/src/utils/richText.ts index 1a9b7083..a8317d7f 100644 --- a/src/utils/richText.ts +++ b/src/utils/richText.ts @@ -9,6 +9,10 @@ export const ALIGNMENTS = ["left", "center", "right"] as const; export type Alignment = (typeof ALIGNMENTS)[number]; +export const HEADING_LEVELS = [1, 2, 3, 4] as const; + +export type HeadingLevel = (typeof HEADING_LEVELS)[number]; + /** * Output formats RichTextEditor can read and emit. Tiptap JSON is the canonical one — every other * format is derived from it, so anything the schema does not model is lost on the way out. @@ -21,21 +25,21 @@ export type RichTextValue = F extends "json" ? JSONCon * Schema shared by the editor and the converters below. Any extension added here changes the * document schema, so RichTextEditor must build its editor from this same list — otherwise a * document written by the editor could fail to round-trip through the converters. + * + * `TextStyle` and `FontSize` no longer have a toolbar control, but stay in the schema so documents + * saved with the old font-size selector still load. */ export const richTextExtensions = [ StarterKit.configure({ - heading: false, - bulletList: false, - orderedList: false, - listItem: false, + heading: { levels: [...HEADING_LEVELS] }, strike: false, code: false, codeBlock: false, - link: false, underline: false, + link: { openOnClick: false, autolink: true, defaultProtocol: "https" }, }), TextAlign.configure({ - types: ["paragraph"], + types: ["heading", "paragraph"], alignments: [...ALIGNMENTS], defaultAlignment: "left", }), diff --git a/src/utils/searchParams.ts b/src/utils/searchParams.ts new file mode 100644 index 00000000..a0ae8f32 --- /dev/null +++ b/src/utils/searchParams.ts @@ -0,0 +1,30 @@ +import type { ApiProjectsGetCollectionData } from "../openapi/client/types.gen"; + +export type SearchFilters = NonNullable; + +export function parseSearchParamsFilters(searchParams: URLSearchParams): SearchFilters { + const filters: Record = {}; + + for (const key of new Set(searchParams.keys())) { + const values = searchParams.getAll(key); + filters[key] = values.length > 1 || key.endsWith("[]") ? values : values[0]; + } + + return filters as SearchFilters; +} + +export function toSearchParams(filters: SearchFilters): URLSearchParams { + const params = new URLSearchParams(); + + for (const [key, value] of Object.entries(filters)) { + if (value == null || value === "") continue; + + if (Array.isArray(value)) { + value.forEach((item) => params.append(key, String(item))); + } else { + params.set(key, String(value)); + } + } + + return params; +} diff --git a/src/utils/socialLinks.ts b/src/utils/socialLinks.ts new file mode 100644 index 00000000..109c483a --- /dev/null +++ b/src/utils/socialLinks.ts @@ -0,0 +1,78 @@ +export const SOCIAL_NETWORKS = ["instagram", "facebook", "x", "linkedin"] as const; + +export type SocialNetwork = (typeof SOCIAL_NETWORKS)[number]; + +export type SocialLinks = Record; + +// The API only tags known orgs (no X), so the network is resolved from the host here +const NETWORK_DOMAINS: Record = { + instagram: ["instagram.com"], + facebook: ["facebook.com", "fb.com"], + x: ["x.com", "twitter.com"], + linkedin: ["linkedin.com"], +}; + +// Where a bare username is appended to build the profile link +const NETWORK_PROFILE_URLS: Record = { + instagram: "https://www.instagram.com/", + facebook: "https://www.facebook.com/", + x: "https://x.com/", + linkedin: "https://www.linkedin.com/in/", +}; + +/** + * Adds `https://` when the scheme is missing, like the API does before storing a link. + */ +export function normalizeLinkUrl(value: string): string { + const url = value.trim(); + + return /^[a-z][a-z\d+.-]*:\/\//i.test(url) ? url : `https://${url}`; +} + +export function getSocialNetwork(url: string): SocialNetwork | undefined { + let host: string; + + try { + host = new URL(normalizeLinkUrl(url)).hostname.toLowerCase(); + } catch { + return undefined; + } + + return SOCIAL_NETWORKS.find((network) => + NETWORK_DOMAINS[network].some((domain) => host === domain || host.endsWith(`.${domain}`)), + ); +} + +/** + * Turns a profile link or a bare username (`@` optional) into a link of the given network. + * @returns `undefined` when the value is neither + */ +export function toSocialLinkUrl(network: SocialNetwork, value: string): string | undefined { + const input = value.trim(); + const linkNetwork = getSocialNetwork(input); + + if (linkNetwork) { + return linkNetwork === network ? normalizeLinkUrl(input) : undefined; + } + + const username = input.replace(/^@/, ""); + + return /^[\w.-]+$/.test(username) ? NETWORK_PROFILE_URLS[network] + username : undefined; +} + +/** + * Picks the first link of each network from a User's links. + */ +export function toSocialLinks(urls: string[]): SocialLinks { + const links: SocialLinks = { instagram: "", facebook: "", x: "", linkedin: "" }; + + for (const url of urls) { + const network = getSocialNetwork(url); + + if (network && !links[network]) { + links[network] = url; + } + } + + return links; +} diff --git a/src/utils/territory.ts b/src/utils/territory.ts index add33968..db5584db 100644 --- a/src/utils/territory.ts +++ b/src/utils/territory.ts @@ -2,6 +2,12 @@ import iso3166 from "iso-3166-2"; import type { Territory } from "../openapi/client"; +/** + * The API stores a territory without a country as this ISO 3166-1 "user assigned" code. It is what + * `Territory::__construct()` falls back to, so forms must treat it as no country at all. + */ +export const UNKNOWN_COUNTRY_CODE = "ZZ"; + export function getTerritoryDisplayName(territory: Territory, lang: Intl.LocalesArgument): string { const countryNames = new Intl.DisplayNames(lang, { type: "region" }); const country = countryNames.of(territory.country!); diff --git a/src/validation/personalProfileValidation.ts b/src/validation/personalProfileValidation.ts new file mode 100644 index 00000000..7bb067a9 --- /dev/null +++ b/src/validation/personalProfileValidation.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; + +import { isValidTaxId } from "../utils/taxId"; + +const zRequiredField = () => + z.string().refine((value) => value.trim().length > 0, { + error: "pages.me.manage.validation.required", + }); + +/** + * The private half of the promoter profile: the `Person` record (names and tax id) plus the + * `Territory` living on the `User`. Every field here is only exposed by the API to the owner and + * to platform admins, which is what separates this form from the public one. + * + * `avatar`, `description` and `links` live on the `User` and are shared with the public section, + * but the pieces that edit them (image card, bio card and social networks) live here too, so they + * travel with the rest of the form. + * + * `type` is not editable: it decides where the tax id belongs, and changing the profile kind is + * done in the public section. + */ +export const zPersonalProfileForm = z + .object({ + handle: z.string().regex(/^[a-z0-9_]{4,30}$/, { + error: "pages.me.manage.validation.handleInvalid", + }), + firstName: z.string(), + lastName: z.string(), + taxId: z.string(), + type: z.enum(["individual", "organization"]), + legalName: z.string(), + businessName: z.string(), + country: z.string().length(2).or(z.literal("")), + subLvl1: z.string(), + subLvl2: z.string(), + address: z.string(), + }) + .superRefine((data, ctx) => { + const taxId = data.taxId.trim(); + + if (data.country && taxId && isValidTaxId(data.country, data.type, taxId) === false) { + ctx.addIssue({ + code: "custom", + path: ["taxId"], + message: "pages.me.manage.validation.taxIdInvalid", + }); + } + + if (data.type !== "organization") { + return; + } + + // The API requires both fields on the Organization record + const result = z + .object({ legalName: zRequiredField(), taxId: zRequiredField() }) + .safeParse({ legalName: data.legalName, taxId: data.taxId }); + + if (!result.success) { + for (const issue of result.error.issues) { + ctx.addIssue({ ...issue, path: [issue.path[0]!] }); + } + } + }); + +export type PersonalProfileForm = z.infer; diff --git a/src/validation/publicProfileValidation.ts b/src/validation/publicProfileValidation.ts new file mode 100644 index 00000000..b5b773a3 --- /dev/null +++ b/src/validation/publicProfileValidation.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; + +import { SOCIAL_NETWORKS, toSocialLinkUrl } from "../utils/socialLinks"; + +export const zProfileForm = z + .object({ + avatar: z.url().optional(), + description: z.string(), + links: z.object({ + instagram: z.string(), + facebook: z.string(), + x: z.string(), + linkedin: z.string(), + }), + }) + .superRefine((data, ctx) => { + // A username or a link of this network; one from another network would land in the wrong field + for (const network of SOCIAL_NETWORKS) { + const url = data.links[network].trim(); + + if (url && !toSocialLinkUrl(network, url)) { + ctx.addIssue({ + code: "custom", + path: ["links", network], + message: "pages.me.manage.validation.linkInvalid", + }); + } + } + }); + +export type ProfileForm = z.infer; diff --git a/static/dtf.md b/static/dtf.md new file mode 100644 index 00000000..af79d638 --- /dev/null +++ b/static/dtf.md @@ -0,0 +1,51 @@ +# **Democratic Tech Fund** + +Our digital lives run on infrastructure we don’t own. The clouds where we work, the platforms where we organise, the social networks where we speak and the tools that sustain our communities are increasingly controlled by a handful of corporations. + +## **Democratic Tech Fund (DTF) exists to change that.** + +DTF is a growing federation of cooperatives, communities and public-interest organisations building **contra-infrastructure**: technology, knowledge and funding that communities can own, govern and sustain together. + +We don’t need to start from zero. Alternatives already exist. DTF connects them, strengthens them and helps them travel. + +## **BUILD → LEARN → FUND → FEDERATE** + +**Build** Cooperative, open and community-governed digital infrastructure. + +**Learn** Shared knowledge, peer learning and support for communities moving towards digital autonomy. + +**Fund** Collective funding mechanisms that turn community priorities into viable projects. + +**Federate** Shared infrastructure, resources and solidarity across communities, cities and borders. + +DTF is not only about replacing one piece of software with another. It is about reclaiming the infrastructures through which we **live, organise, care, remember and act together**. + +# **DTF BY GOTEO** + +**Crowdfunding the infrastructure we want to live with.** + +**DTF by Goteo is the crowdfunding arm of the Democratic Tech Fund.** + +Built with Fundación Platoniq and Goteo, it turns DTF’s mission into a practical funding pipeline: communities identify needs, projects emerge and are validated, citizens back them through crowdfunding, and institutions and funding partners can **match the crowd**. + +Instead of deciding behind closed doors what deserves funding, we use crowdfunding as a form of **distributed decision-making and civic validation**. + +## **COMMUNITY → PROJECT → CROWD → MATCH → COMMONS** + +A community identifies a need. A project proposes an alternative. The crowd decides to support it. Funders multiply that support through match-funding. Resources flow into infrastructure that creates collective value. + +Goteo brings more than fifteen years of experience in civic crowdfunding, with **over €25 million mobilised**, a community of more than **400,000 people**, and thousands of commons-oriented initiatives supported. + +**FROM FUNDING PROJECTS TO BUILDING ECOSYSTEMS** + +DTF by Goteo organises funding around thematic programmes or **Shelves**: curated spaces where communities, organisations and funders can gather projects around a shared challenge. + +A Shelf can support anything from community clouds and federated social media to cooperative platforms, democratic AI, collective archives and technologies that sustain **life and activism**. + +Organisations can also become **Shelf Stewards**: bring a community, define a challenge and contribute a funding pool. DTF and its partners can combine these resources with crowdfunding and match-funding so that every contribution helps unlock more collective investment. + +## **Don’t just fund another platform. Help build the digital commons we need.** + +**JOIN THE FEDERATION · START A SHELF · FUND A PROJECT · BACK A CAMPAIGN** + +## **The network is ours. Let’s take it back.** diff --git a/wrangler.toml b/wrangler.toml index 3a93c98f..cb718912 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,64 +1,37 @@ -name = "goteov4web-app" +name = "dtfcrowd-app" main = "@astrojs/cloudflare/entrypoints/server" compatibility_date = "2025-06-26" compatibility_flags = ["nodejs_compat"] -workers_dev = false -keep_vars = true - - -# ============================================================================ -# LOCAL DEVELOPMENT -# ============================================================================ [[d1_databases]] binding = "DB" -database_name = "goteov4web-db" -database_id = "1f119ee7-358a-4eba-b076-40248d4f28f6" +database_name = "dtfcrowd-db" +database_id = "6c3e6748-7e5c-450d-9565-d24739442341" migrations_dir = "db/migrations" - [assets] binding = "ASSETS" directory = "./dist" - [observability.logs] enabled = true -# ============================================================================ -# TESTING -# ============================================================================ - -[env.testing] -name = "goteov4web-app-testing" - -routes = [ - { pattern = "testing.v4.goteo.org", zone_name = "goteo.org", custom_domain = true }, -] - -[[env.testing.d1_databases]] -binding = "DB" -database_name = "goteov4web-db-testing" -database_id = "5f54dc4e-811b-438b-b83e-9acee224f250" -migrations_dir = "db/migrations" - - # ============================================================================ # STAGING # ============================================================================ [env.staging] -name = "goteov4web-app-staging" +name = "dtfcrowd-app-staging" routes = [ - { pattern = "staging.v4.goteo.org", zone_name = "goteo.org", custom_domain = true }, + { pattern = "dtf-staging.v4.goteo.org", zone_name = "goteo.org", custom_domain = true }, ] [[env.staging.d1_databases]] binding = "DB" -database_name = "goteov4web-db-staging" -database_id = "b6afee51-6e91-4e25-b7c0-fb9b166b4cda" +database_name = "dtfcrowd-db-staging" +database_id = "9c338a5d-f860-488c-b0c0-46b5b74f0492" migrations_dir = "db/migrations" @@ -67,14 +40,14 @@ migrations_dir = "db/migrations" # ============================================================================ [env.prod] -name = "goteov4web-app-prod" +name = "dtfcrowd-app-prod" routes = [ - { pattern = "v4.goteo.org", zone_name = "goteo.org", custom_domain = true }, + { pattern = "dtf.v4.goteo.org", zone_name = "goteo.org", custom_domain = true }, ] [[env.prod.d1_databases]] binding = "DB" -database_name = "goteov4web-db-prod" -database_id = "65fe9b71-9263-400e-ae7f-7d899493ee99" +database_name = "dtfcrowd-db-prod" +database_id = "b682c189-c6f2-4932-aae0-f08185efeba9" migrations_dir = "db/migrations"